Public Access
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
269699fd6f | ||
|
|
b18fef3f33 | ||
|
|
dd8e6c69e9 | ||
|
|
ccb7023965 | ||
|
|
7dd15f1461 | ||
|
|
d4e4621fa1 | ||
|
|
a6f814c446 | ||
|
|
04aa5acb1f | ||
|
|
6973f9d851 | ||
|
|
2669a0ea73 | ||
|
|
03f057b55a | ||
|
|
706d6dafe0 | ||
|
|
03ddce427c | ||
|
|
9642d6884c | ||
|
|
b2074d6635 | ||
|
|
a6dddf25e7 | ||
|
|
01130a7385 | ||
|
|
07580c9280 | ||
|
|
6601d90bee | ||
|
|
0df79fed53 | ||
|
|
cf8287e683 | ||
|
|
9f1bdc4cf1 |
@@ -1,9 +0,0 @@
|
|||||||
extends: existence
|
|
||||||
message: "Use 'AM' or 'PM' (preceded by a space)."
|
|
||||||
link: "https://developers.google.com/style/word-list"
|
|
||||||
level: error
|
|
||||||
nonword: true
|
|
||||||
tokens:
|
|
||||||
- '\d{1,2}[AP]M\b'
|
|
||||||
- '\d{1,2} ?[ap]m\b'
|
|
||||||
- '\d{1,2} ?[aApP]\.[mM]\.'
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
extends: conditional
|
|
||||||
message: "Spell out '%s', if it's unfamiliar to the audience."
|
|
||||||
link: 'https://developers.google.com/style/abbreviations'
|
|
||||||
level: suggestion
|
|
||||||
ignorecase: false
|
|
||||||
# Ensures that the existence of 'first' implies the existence of 'second'.
|
|
||||||
first: '\b([A-Z]{3,5})\b'
|
|
||||||
second: '(?:\b[A-Z][a-z]+ )+\(([A-Z]{3,5})\)'
|
|
||||||
# ... with the exception of these:
|
|
||||||
exceptions:
|
|
||||||
- API
|
|
||||||
- ASP
|
|
||||||
- CLI
|
|
||||||
- CPU
|
|
||||||
- CSS
|
|
||||||
- CSV
|
|
||||||
- DEBUG
|
|
||||||
- DOM
|
|
||||||
- DPI
|
|
||||||
- FAQ
|
|
||||||
- GCC
|
|
||||||
- GDB
|
|
||||||
- GET
|
|
||||||
- GPU
|
|
||||||
- GTK
|
|
||||||
- GUI
|
|
||||||
- HTML
|
|
||||||
- HTTP
|
|
||||||
- HTTPS
|
|
||||||
- IDE
|
|
||||||
- JAR
|
|
||||||
- JSON
|
|
||||||
- JSX
|
|
||||||
- LESS
|
|
||||||
- LLDB
|
|
||||||
- NET
|
|
||||||
- NOTE
|
|
||||||
- NVDA
|
|
||||||
- OSS
|
|
||||||
- PATH
|
|
||||||
- PDF
|
|
||||||
- PHP
|
|
||||||
- POST
|
|
||||||
- RAM
|
|
||||||
- REPL
|
|
||||||
- RSA
|
|
||||||
- SCM
|
|
||||||
- SCSS
|
|
||||||
- SDK
|
|
||||||
- SQL
|
|
||||||
- SSH
|
|
||||||
- SSL
|
|
||||||
- SVG
|
|
||||||
- TBD
|
|
||||||
- TCP
|
|
||||||
- TODO
|
|
||||||
- URI
|
|
||||||
- URL
|
|
||||||
- USB
|
|
||||||
- UTF
|
|
||||||
- XML
|
|
||||||
- XSS
|
|
||||||
- YAML
|
|
||||||
- ZIP
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
extends: existence
|
|
||||||
message: "Don't attribute human qualities to software or hardware ('%s')."
|
|
||||||
link: https://developers.google.com/style/anthropomorphism
|
|
||||||
level: suggestion
|
|
||||||
ignorecase: true
|
|
||||||
# Limited to the two verbs the guide itself names. Broader lists (wants, knows,
|
|
||||||
# thinks) can't tell a software subject from a human one: on a 950-file corpus
|
|
||||||
# they produced 8 false positives ('the customer wants', 'your audience knows')
|
|
||||||
# for every 2 real ones.
|
|
||||||
tokens:
|
|
||||||
- sees
|
|
||||||
- tells
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
extends: existence
|
|
||||||
message: "'%s' should be in lowercase."
|
|
||||||
link: 'https://developers.google.com/style/colons'
|
|
||||||
level: warning
|
|
||||||
scope: sentence
|
|
||||||
# The match is the word itself, not ': X', and `nonword` is off. Both are
|
|
||||||
# required for a project Vocab to work: Vale compares accept.txt entries
|
|
||||||
# against the matched text, and `nonword: true` opts out of that entirely.
|
|
||||||
# So a proper noun after a colon can be exempted by adding it to accept.txt.
|
|
||||||
# The guide's other exemption, notice labels, is handled by the lookbehinds;
|
|
||||||
# headings are already excluded by `scope: sentence`. See issue #20.
|
|
||||||
tokens:
|
|
||||||
- '(?<!Note: )(?<!Caution: )(?<!Warning: )(?<!Success: )(?<=:\s)[A-Z]\w+'
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
extends: substitution
|
|
||||||
message: "Use '%s' instead of '%s'."
|
|
||||||
link: 'https://developers.google.com/style/contractions'
|
|
||||||
level: suggestion
|
|
||||||
ignorecase: true
|
|
||||||
action:
|
|
||||||
name: replace
|
|
||||||
swap:
|
|
||||||
are not: aren't
|
|
||||||
cannot: can't
|
|
||||||
could not: couldn't
|
|
||||||
did not: didn't
|
|
||||||
do not: don't
|
|
||||||
does not: doesn't
|
|
||||||
has not: hasn't
|
|
||||||
have not: haven't
|
|
||||||
how is: how's
|
|
||||||
is not: isn't
|
|
||||||
it is: it's
|
|
||||||
should not: shouldn't
|
|
||||||
that is: that's
|
|
||||||
they are: they're
|
|
||||||
was not: wasn't
|
|
||||||
we are: we're
|
|
||||||
we have: we've
|
|
||||||
were not: weren't
|
|
||||||
what is: what's
|
|
||||||
when is: when's
|
|
||||||
where is: where's
|
|
||||||
will not: won't
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
extends: existence
|
|
||||||
message: "Use 'July 31, 2016' format, not '%s'."
|
|
||||||
link: 'https://developers.google.com/style/dates-times'
|
|
||||||
ignorecase: true
|
|
||||||
level: error
|
|
||||||
nonword: true
|
|
||||||
tokens:
|
|
||||||
- '\d{1,2}(?:\.|/)\d{1,2}(?:\.|/)\d{4}'
|
|
||||||
- '\d{1,2} (?:Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:tember)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?) \d{4}'
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
extends: existence
|
|
||||||
message: "In general, don't use an ellipsis."
|
|
||||||
link: 'https://developers.google.com/style/ellipses'
|
|
||||||
nonword: true
|
|
||||||
level: warning
|
|
||||||
action:
|
|
||||||
name: remove
|
|
||||||
tokens:
|
|
||||||
- '\.\.\.'
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
extends: existence
|
|
||||||
message: "Don't put a space before or after a dash."
|
|
||||||
link: "https://developers.google.com/style/dashes"
|
|
||||||
nonword: true
|
|
||||||
level: error
|
|
||||||
action:
|
|
||||||
name: edit
|
|
||||||
params:
|
|
||||||
- trim
|
|
||||||
- " "
|
|
||||||
tokens:
|
|
||||||
- '\s[—–]\s'
|
|
||||||
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
extends: existence
|
|
||||||
message: "Avoid the unverifiable claim '%s'."
|
|
||||||
link: https://developers.google.com/style/excessive-claims
|
|
||||||
level: suggestion
|
|
||||||
ignorecase: true
|
|
||||||
# The guide also names 'never', 'always', and 'ensure', but in technical writing
|
|
||||||
# those are usually legitimate instructions ('never commit secrets') rather than
|
|
||||||
# product claims: they accounted for 125 of 142 hits on a 950-file corpus.
|
|
||||||
# 'best practices' is a fixed term, not a superlative.
|
|
||||||
tokens:
|
|
||||||
- 'best(?! practices?)'
|
|
||||||
- simplest
|
|
||||||
- fastest
|
|
||||||
- guarantees?
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
extends: existence
|
|
||||||
message: "Don't use exclamation points in text."
|
|
||||||
link: "https://developers.google.com/style/exclamation-points"
|
|
||||||
nonword: true
|
|
||||||
level: error
|
|
||||||
action:
|
|
||||||
name: edit
|
|
||||||
params:
|
|
||||||
- trim_right
|
|
||||||
- "!"
|
|
||||||
tokens:
|
|
||||||
- '\w+!(?:\s|$)'
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
extends: existence
|
|
||||||
message: "Avoid first-person pronouns such as '%s'."
|
|
||||||
link: 'https://developers.google.com/style/pronouns#personal-pronouns'
|
|
||||||
ignorecase: true
|
|
||||||
level: warning
|
|
||||||
# The 'I' tokens use lookaround rather than consuming the surrounding
|
|
||||||
# whitespace. Matching ' I ' made the alert span cover both spaces, which shows
|
|
||||||
# up as a too-wide underline in editors, and read as "such as ' I '". Dropping
|
|
||||||
# `nonword` also lets a project Vocab apply, which it can't when set. See PR #50.
|
|
||||||
tokens:
|
|
||||||
- '(?<=^|\s)I(?=[\s,])'
|
|
||||||
- "\\bI'm\\b"
|
|
||||||
- \bme\b
|
|
||||||
- \bmy\b
|
|
||||||
- \bmine\b
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
extends: existence
|
|
||||||
message: "Don't use '%s' as a gender-neutral pronoun."
|
|
||||||
link: 'https://developers.google.com/style/pronouns#gender-neutral-pronouns'
|
|
||||||
level: error
|
|
||||||
ignorecase: true
|
|
||||||
tokens:
|
|
||||||
- he/she
|
|
||||||
- s/he
|
|
||||||
- \(s\)he
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
extends: substitution
|
|
||||||
message: "Consider using '%s' instead of '%s'."
|
|
||||||
ignorecase: true
|
|
||||||
link: "https://developers.google.com/style/inclusive-documentation"
|
|
||||||
level: error
|
|
||||||
action:
|
|
||||||
name: replace
|
|
||||||
swap:
|
|
||||||
(?:alumna|alumnus): graduate
|
|
||||||
(?:alumnae|alumni): graduates
|
|
||||||
air(?:m[ae]n|wom[ae]n): pilot(s)
|
|
||||||
anchor(?:m[ae]n|wom[ae]n): anchor(s)
|
|
||||||
authoress: author
|
|
||||||
camera(?:m[ae]n|wom[ae]n): camera operator(s)
|
|
||||||
door(?:m[ae]|wom[ae]n): concierge(s)
|
|
||||||
draft(?:m[ae]n|wom[ae]n): drafter(s)
|
|
||||||
fire(?:m[ae]n|wom[ae]n): firefighter(s)
|
|
||||||
fisher(?:m[ae]n|wom[ae]n): fisher(s)
|
|
||||||
fresh(?:m[ae]n|wom[ae]n): first-year student(s)
|
|
||||||
garbage(?:m[ae]n|wom[ae]n): waste collector(s)
|
|
||||||
lady lawyer: lawyer
|
|
||||||
ladylike: courteous
|
|
||||||
mail(?:m[ae]n|wom[ae]n): mail carriers
|
|
||||||
man and wife: husband and wife
|
|
||||||
man enough: strong enough
|
|
||||||
mankind: human kind|humanity
|
|
||||||
manmade: manufactured
|
|
||||||
manpower: personnel
|
|
||||||
middle(?:m[ae]n|wom[ae]n): intermediary
|
|
||||||
news(?:m[ae]n|wom[ae]n): journalist(s)
|
|
||||||
ombuds(?:man|woman): ombuds
|
|
||||||
oneupmanship: upstaging
|
|
||||||
poetess: poet
|
|
||||||
police(?:m[ae]n|wom[ae]n): police officer(s)
|
|
||||||
repair(?:m[ae]n|wom[ae]n): technician(s)
|
|
||||||
sales(?:m[ae]n|wom[ae]n): salesperson or sales people
|
|
||||||
service(?:m[ae]n|wom[ae]n): soldier(s)
|
|
||||||
steward(?:ess)?: flight attendant
|
|
||||||
tribes(?:m[ae]n|wom[ae]n): tribe member(s)
|
|
||||||
waitress: waiter
|
|
||||||
woman doctor: doctor
|
|
||||||
woman scientist[s]?: scientist(s)
|
|
||||||
work(?:m[ae]n|wom[ae]n): worker(s)
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
extends: existence
|
|
||||||
message: "Don't put a period at the end of a heading."
|
|
||||||
link: "https://developers.google.com/style/capitalization#capitalization-in-titles-and-headings"
|
|
||||||
nonword: true
|
|
||||||
level: warning
|
|
||||||
scope: heading
|
|
||||||
action:
|
|
||||||
name: edit
|
|
||||||
params:
|
|
||||||
- trim_right
|
|
||||||
- "."
|
|
||||||
tokens:
|
|
||||||
- '[a-z0-9][.]\s*$'
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
extends: capitalization
|
|
||||||
message: "'%s' should use sentence-style capitalization."
|
|
||||||
link: "https://developers.google.com/style/capitalization#capitalization-in-titles-and-headings"
|
|
||||||
level: warning
|
|
||||||
scope: heading
|
|
||||||
match: $sentence
|
|
||||||
# No `indicators: [":"]` here. That makes Vale require a capital after a colon,
|
|
||||||
# which is the Microsoft convention this rule was originally copied from. This
|
|
||||||
# guide says the opposite: "the first word after a colon is generally
|
|
||||||
# lowercase" (developers.google.com/style/colons), and Colons.yml enforces
|
|
||||||
# exactly that. See issue #58.
|
|
||||||
exceptions:
|
|
||||||
- Azure
|
|
||||||
- CLI
|
|
||||||
- Cosmos
|
|
||||||
- Docker
|
|
||||||
- Emmet
|
|
||||||
- gRPC
|
|
||||||
- I
|
|
||||||
- Kubernetes
|
|
||||||
- Linux
|
|
||||||
- macOS
|
|
||||||
- Marketplace
|
|
||||||
- MongoDB
|
|
||||||
- REPL
|
|
||||||
- Studio
|
|
||||||
- TypeScript
|
|
||||||
- URLs
|
|
||||||
- Visual
|
|
||||||
- VS
|
|
||||||
- Windows
|
|
||||||
- JSON
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
extends: existence
|
|
||||||
message: "Avoid the jargon '%s'."
|
|
||||||
link: https://developers.google.com/style/jargon
|
|
||||||
level: suggestion
|
|
||||||
ignorecase: true
|
|
||||||
# The guide also cites 'solution', 'support', and 'workload' as overloaded
|
|
||||||
# terms, but those have ordinary technical meanings and accounted for every hit
|
|
||||||
# on a 950-file corpus, so only the unambiguous figurative terms are listed.
|
|
||||||
tokens:
|
|
||||||
- break-glass
|
|
||||||
- camel ?case
|
|
||||||
- out-of-the-box
|
|
||||||
- swim ?lane
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
extends: substitution
|
|
||||||
message: "Use '%s' instead of '%s'."
|
|
||||||
link: 'https://developers.google.com/style/abbreviations'
|
|
||||||
ignorecase: true
|
|
||||||
level: error
|
|
||||||
nonword: true
|
|
||||||
action:
|
|
||||||
name: replace
|
|
||||||
# The delimiter is a lookahead so the replacement doesn't swallow the comma or
|
|
||||||
# space that follows (issue #18). `$` is included so the abbreviation is still
|
|
||||||
# caught at the end of a heading, table cell, or block, which accounted for 8
|
|
||||||
# of 10 occurrences on a 950-file corpus.
|
|
||||||
swap:
|
|
||||||
'\b(?:eg|e\.g\.)(?=[\s,;]|$)': for example
|
|
||||||
'\b(?:ie|i\.e\.)(?=[\s,;]|$)': that is
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
extends: existence
|
|
||||||
message: "'%s' doesn't need a hyphen."
|
|
||||||
link: "https://developers.google.com/style/hyphens"
|
|
||||||
level: error
|
|
||||||
ignorecase: false
|
|
||||||
nonword: true
|
|
||||||
action:
|
|
||||||
name: edit
|
|
||||||
params:
|
|
||||||
- regex
|
|
||||||
- "-"
|
|
||||||
- " "
|
|
||||||
tokens:
|
|
||||||
- '\b[^\s-]+ly-\w+\b'
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
extends: existence
|
|
||||||
message: "Don't use plurals in parentheses such as in '%s'."
|
|
||||||
link: "https://developers.google.com/style/plurals-parentheses"
|
|
||||||
level: error
|
|
||||||
nonword: true
|
|
||||||
action:
|
|
||||||
name: edit
|
|
||||||
params:
|
|
||||||
- trim_right
|
|
||||||
- "(s)"
|
|
||||||
tokens:
|
|
||||||
- '\b\w+\(s\)'
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
extends: existence
|
|
||||||
message: "Spell out all ordinal numbers ('%s') in text."
|
|
||||||
link: 'https://developers.google.com/style/numbers'
|
|
||||||
level: error
|
|
||||||
nonword: true
|
|
||||||
tokens:
|
|
||||||
- \d+(?:st|nd|rd|th)
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
extends: existence
|
|
||||||
message: "Use the Oxford comma in '%s'."
|
|
||||||
link: 'https://developers.google.com/style/commas'
|
|
||||||
scope: sentence
|
|
||||||
level: warning
|
|
||||||
nonword: true
|
|
||||||
# List items may be several words long, not just one. Four guards keep the
|
|
||||||
# false-positive rate down:
|
|
||||||
#
|
|
||||||
# 1. The comma can't be the one closing a fronted subordinate clause
|
|
||||||
# ('When your alarm rings, you turn it off and tumble out of bed.') --
|
|
||||||
# that comma separates clauses, not list items. Only the first comma of
|
|
||||||
# such a sentence is exempt, so 'When it rains, apples, pears or bananas
|
|
||||||
# get wet.' is still caught.
|
|
||||||
# 2. The item can't open with a clause-introducer (', which ...',
|
|
||||||
# ', specifically ...').
|
|
||||||
# 3. The item can't open with a subject pronoun followed by a verb, which
|
|
||||||
# marks a compound predicate rather than a list ('..., you walk to the
|
|
||||||
# fridge and get a snack.'). A pronoun directly followed by 'and'/'or'
|
|
||||||
# is a real list item, so ', you and me.' still matches.
|
|
||||||
# 4. Neither item may contain an auxiliary verb, which is another compound
|
|
||||||
# predicate signal (', it has some downsides and is officially
|
|
||||||
# discouraged.').
|
|
||||||
#
|
|
||||||
# The trailing anchor allows end-of-scope so list fragments ('Apples, pears
|
|
||||||
# or bananas') are still caught.
|
|
||||||
tokens:
|
|
||||||
- '(?<!^(?i:when|whenever|while|if|unless|until|although|though|because|since|after|before|once|whereas|whether|as)\b[^,]{0,80}),\s(?!(?:which|who|whom|whose|that|where|when|while|because|since|although|though|if|unless|so|but|and|or|however|therefore|thus|specifically|especially|namely|then|take|see|note|consider|make|use|either|neither)\b)(?!(?i:i|you|we|they|he|she|it)\s+(?!(?:and|or)\b))(?:(?!\b(?:is|are|was|were|has|have|had|be|been|being|will|would|can|could|should|may|might|must|do|does|did)\b)\w+ ){0,4}\w+ (?:and|or) (?:(?!\b(?:is|are|was|were|has|have|had|be|been|being|will|would|can|could|should|may|might|must|do|does|did)\b)\w+ ){0,4}\w+(?:[.?!]|$)'
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
extends: existence
|
|
||||||
message: "Use parentheses judiciously."
|
|
||||||
link: 'https://developers.google.com/style/parentheses'
|
|
||||||
nonword: true
|
|
||||||
level: suggestion
|
|
||||||
# `[^)]` rather than `.+`: a greedy match ran from the first '(' on a line to
|
|
||||||
# the last ')', so 'Text (one) and more (two).' produced a single alert
|
|
||||||
# covering everything between them. See issue #30.
|
|
||||||
# A bare 3-5 letter acronym is skipped: Acronyms.yml requires acronyms to be
|
|
||||||
# defined as 'Spelled Out Term (ACRONYM)', so flagging those parentheses would
|
|
||||||
# put the two rules in direct conflict. The acronym has to be the whole
|
|
||||||
# parenthetical — '(NASA rocket program)' is an ordinary aside and still
|
|
||||||
# flags. Length matches the {3,5} in Acronyms.yml. See PR #59.
|
|
||||||
tokens:
|
|
||||||
- '\((?![A-Z]{3,5}\))[^)]+\)'
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
extends: existence
|
|
||||||
link: 'https://developers.google.com/style/voice'
|
|
||||||
message: "In general, use active voice instead of passive voice ('%s')."
|
|
||||||
ignorecase: true
|
|
||||||
level: suggestion
|
|
||||||
raw:
|
|
||||||
- \b(am|are|were|being|is|been|was|be)\b\s*
|
|
||||||
tokens:
|
|
||||||
- '[\w]+ed'
|
|
||||||
- awoken
|
|
||||||
- beat
|
|
||||||
- become
|
|
||||||
- been
|
|
||||||
- begun
|
|
||||||
- bent
|
|
||||||
- beset
|
|
||||||
- bet
|
|
||||||
- bid
|
|
||||||
- bidden
|
|
||||||
- bitten
|
|
||||||
- bled
|
|
||||||
- blown
|
|
||||||
- born
|
|
||||||
- bought
|
|
||||||
- bound
|
|
||||||
- bred
|
|
||||||
- broadcast
|
|
||||||
- broken
|
|
||||||
- brought
|
|
||||||
- built
|
|
||||||
- burnt
|
|
||||||
- burst
|
|
||||||
- cast
|
|
||||||
- caught
|
|
||||||
- chosen
|
|
||||||
- clung
|
|
||||||
- come
|
|
||||||
- cost
|
|
||||||
- crept
|
|
||||||
- cut
|
|
||||||
- dealt
|
|
||||||
- dived
|
|
||||||
- done
|
|
||||||
- drawn
|
|
||||||
- dreamt
|
|
||||||
- driven
|
|
||||||
- drunk
|
|
||||||
- dug
|
|
||||||
- eaten
|
|
||||||
- fallen
|
|
||||||
- fed
|
|
||||||
- felt
|
|
||||||
- fit
|
|
||||||
- fled
|
|
||||||
- flown
|
|
||||||
- flung
|
|
||||||
- forbidden
|
|
||||||
- foregone
|
|
||||||
- forgiven
|
|
||||||
- forgotten
|
|
||||||
- forsaken
|
|
||||||
- fought
|
|
||||||
- found
|
|
||||||
- frozen
|
|
||||||
- given
|
|
||||||
- gone
|
|
||||||
- gotten
|
|
||||||
- ground
|
|
||||||
- grown
|
|
||||||
- heard
|
|
||||||
- held
|
|
||||||
- hidden
|
|
||||||
- hit
|
|
||||||
- hung
|
|
||||||
- hurt
|
|
||||||
- kept
|
|
||||||
- knelt
|
|
||||||
- knit
|
|
||||||
- known
|
|
||||||
- laid
|
|
||||||
- lain
|
|
||||||
- leapt
|
|
||||||
- learnt
|
|
||||||
- led
|
|
||||||
- left
|
|
||||||
- lent
|
|
||||||
- let
|
|
||||||
- lighted
|
|
||||||
- lost
|
|
||||||
- made
|
|
||||||
- meant
|
|
||||||
- met
|
|
||||||
- misspelt
|
|
||||||
- mistaken
|
|
||||||
- mown
|
|
||||||
- overcome
|
|
||||||
- overdone
|
|
||||||
- overtaken
|
|
||||||
- overthrown
|
|
||||||
- paid
|
|
||||||
- pled
|
|
||||||
- proven
|
|
||||||
- put
|
|
||||||
- quit
|
|
||||||
- read
|
|
||||||
- rid
|
|
||||||
- ridden
|
|
||||||
- risen
|
|
||||||
- run
|
|
||||||
- rung
|
|
||||||
- said
|
|
||||||
- sat
|
|
||||||
- sawn
|
|
||||||
- seen
|
|
||||||
- sent
|
|
||||||
- set
|
|
||||||
- sewn
|
|
||||||
- shaken
|
|
||||||
- shaven
|
|
||||||
- shed
|
|
||||||
- shod
|
|
||||||
- shone
|
|
||||||
- shorn
|
|
||||||
- shot
|
|
||||||
- shown
|
|
||||||
- shrunk
|
|
||||||
- shut
|
|
||||||
- slain
|
|
||||||
- slept
|
|
||||||
- slid
|
|
||||||
- slit
|
|
||||||
- slung
|
|
||||||
- smitten
|
|
||||||
- sold
|
|
||||||
- sought
|
|
||||||
- sown
|
|
||||||
- sped
|
|
||||||
- spent
|
|
||||||
- spilt
|
|
||||||
- spit
|
|
||||||
- split
|
|
||||||
- spoken
|
|
||||||
- spread
|
|
||||||
- sprung
|
|
||||||
- spun
|
|
||||||
- stolen
|
|
||||||
- stood
|
|
||||||
- stridden
|
|
||||||
- striven
|
|
||||||
- struck
|
|
||||||
- strung
|
|
||||||
- stuck
|
|
||||||
- stung
|
|
||||||
- stunk
|
|
||||||
- sung
|
|
||||||
- sunk
|
|
||||||
- swept
|
|
||||||
- swollen
|
|
||||||
- sworn
|
|
||||||
- swum
|
|
||||||
- swung
|
|
||||||
- taken
|
|
||||||
- taught
|
|
||||||
- thought
|
|
||||||
- thrived
|
|
||||||
- thrown
|
|
||||||
- thrust
|
|
||||||
- told
|
|
||||||
- torn
|
|
||||||
- trodden
|
|
||||||
- understood
|
|
||||||
- upheld
|
|
||||||
- upset
|
|
||||||
- wed
|
|
||||||
- wept
|
|
||||||
- withheld
|
|
||||||
- withstood
|
|
||||||
- woken
|
|
||||||
- won
|
|
||||||
- worn
|
|
||||||
- wound
|
|
||||||
- woven
|
|
||||||
- written
|
|
||||||
- wrung
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
extends: existence
|
|
||||||
message: "Don't use periods with acronyms or initialisms such as '%s'."
|
|
||||||
link: 'https://developers.google.com/style/abbreviations'
|
|
||||||
level: error
|
|
||||||
nonword: true
|
|
||||||
tokens:
|
|
||||||
- '\b(?:[A-Z]\.){3,}'
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
extends: existence
|
|
||||||
message: "Commas and periods go inside quotation marks."
|
|
||||||
link: 'https://developers.google.com/style/quotation-marks'
|
|
||||||
level: error
|
|
||||||
nonword: true
|
|
||||||
tokens:
|
|
||||||
- '"[^"]+"[.,?]'
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
extends: existence
|
|
||||||
message: "Don't add words such as 'from' or 'between' to describe a range of numbers."
|
|
||||||
link: 'https://developers.google.com/style/hyphens'
|
|
||||||
nonword: true
|
|
||||||
level: warning
|
|
||||||
tokens:
|
|
||||||
- '(?:from|between)\s\d+\s?-\s?\d+'
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
extends: existence
|
|
||||||
message: "Use semicolons judiciously."
|
|
||||||
link: 'https://developers.google.com/style/semicolons'
|
|
||||||
nonword: true
|
|
||||||
scope: sentence
|
|
||||||
level: suggestion
|
|
||||||
tokens:
|
|
||||||
- ';'
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
extends: existence
|
|
||||||
message: "Don't use internet slang abbreviations such as '%s'."
|
|
||||||
link: 'https://developers.google.com/style/abbreviations'
|
|
||||||
ignorecase: true
|
|
||||||
level: error
|
|
||||||
tokens:
|
|
||||||
- 'tl;dr'
|
|
||||||
- ymmv
|
|
||||||
- rtfm
|
|
||||||
- imo
|
|
||||||
- fwiw
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
extends: existence
|
|
||||||
message: "'%s' should have one space."
|
|
||||||
link: 'https://developers.google.com/style/sentence-spacing'
|
|
||||||
level: error
|
|
||||||
nonword: true
|
|
||||||
action:
|
|
||||||
name: remove
|
|
||||||
tokens:
|
|
||||||
- '[a-z][.?!] {2,}[A-Z]'
|
|
||||||
- '[a-z][.?!][A-Z]'
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
extends: existence
|
|
||||||
message: "In general, use American spelling instead of '%s'."
|
|
||||||
link: 'https://developers.google.com/style/spelling'
|
|
||||||
ignorecase: true
|
|
||||||
level: warning
|
|
||||||
tokens:
|
|
||||||
- '(?:\w+)nised?'
|
|
||||||
- 'colour'
|
|
||||||
- 'labour'
|
|
||||||
- 'centre'
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
extends: existence
|
|
||||||
message: "Avoid time-based words like '%s' in product documentation."
|
|
||||||
link: https://developers.google.com/style/timeless-documentation
|
|
||||||
level: suggestion
|
|
||||||
ignorecase: true
|
|
||||||
# The guide also names 'now' and 'new', but both have common senses that aren't
|
|
||||||
# time-anchored ('create a new project'): adding them took a 950-file corpus of
|
|
||||||
# technical documentation from 14 hits to 117. 'recently' is left out too — every
|
|
||||||
# hit in that corpus was the UI idiom 'recently used'.
|
|
||||||
tokens:
|
|
||||||
- currently
|
|
||||||
- latest
|
|
||||||
- soon
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
extends: existence
|
|
||||||
message: "Put a nonbreaking space between the number and the unit in '%s'."
|
|
||||||
link: "https://developers.google.com/style/units-of-measure"
|
|
||||||
nonword: true
|
|
||||||
level: error
|
|
||||||
tokens:
|
|
||||||
- '\b\d+(?:B|kB|MB|GB|TB)\b'
|
|
||||||
- '\b\d+(?:ns|ms|min|h|d)\b'
|
|
||||||
# Seconds are split out so a decade ('1990s') isn't read as a unit.
|
|
||||||
- '\b\d+s\b(?<!\b(?:19|20)\d\ds\b)'
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
extends: existence
|
|
||||||
message: "Try to avoid using first-person plural like '%s'."
|
|
||||||
link: 'https://developers.google.com/style/pronouns#personal-pronouns'
|
|
||||||
level: warning
|
|
||||||
ignorecase: true
|
|
||||||
tokens:
|
|
||||||
- we
|
|
||||||
- we'(?:ve|re)
|
|
||||||
- ours?
|
|
||||||
- us
|
|
||||||
- let's
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
extends: existence
|
|
||||||
message: "Avoid using '%s'."
|
|
||||||
link: 'https://developers.google.com/style/tense'
|
|
||||||
ignorecase: true
|
|
||||||
level: warning
|
|
||||||
tokens:
|
|
||||||
- will
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
extends: substitution
|
|
||||||
message: "Use '%s' instead of '%s'."
|
|
||||||
link: "https://developers.google.com/style/word-list"
|
|
||||||
level: warning
|
|
||||||
# Case matters here: each key's own capitalization is what's being corrected,
|
|
||||||
# so ignorecase would make these match their own replacements. The rest of the
|
|
||||||
# word list lives in WordListCase.yml.
|
|
||||||
ignorecase: false
|
|
||||||
action:
|
|
||||||
name: replace
|
|
||||||
swap:
|
|
||||||
Ajax: AJAX
|
|
||||||
Android device: Android-powered device
|
|
||||||
android: Android
|
|
||||||
API explorer: APIs Explorer
|
|
||||||
authN: authentication
|
|
||||||
authZ: authorization
|
|
||||||
CLI: command-line tool
|
|
||||||
Cloud: Google Cloud Platform|GCP
|
|
||||||
Container Engine: Kubernetes Engine
|
|
||||||
Developers Console: Google API Console|API Console
|
|
||||||
Google account: Google Account
|
|
||||||
Google accounts: Google Accounts
|
|
||||||
Googling: search with Google
|
|
||||||
HTTPs: HTTPS
|
|
||||||
k8s: Kubernetes
|
|
||||||
SHA1: SHA-1|HAS-SHA1
|
|
||||||
url: URL
|
|
||||||
World Wide Web: web
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
extends: substitution
|
|
||||||
message: "Use '%s' instead of '%s'."
|
|
||||||
link: "https://developers.google.com/style/word-list"
|
|
||||||
level: warning
|
|
||||||
# The case-insensitive half of the word list, so sentence-initial use is caught
|
|
||||||
# ('Touch the screen', not only 'touch the screen'). Entries that must stay
|
|
||||||
# case-sensitive are in WordList.yml.
|
|
||||||
ignorecase: true
|
|
||||||
action:
|
|
||||||
name: replace
|
|
||||||
swap:
|
|
||||||
"(?:API Console|dev|developer) key": API key
|
|
||||||
"(?:cell ?phone|smart ?phone)": phone|mobile phone
|
|
||||||
"(?:dev|developer|APIs) console": API console
|
|
||||||
"(?:e-mail|Email|E-mail)": email
|
|
||||||
"(?:file ?path|path ?name)": path
|
|
||||||
"(?:kill|terminate|abort)": stop|exit|cancel|end
|
|
||||||
# Longest form first: with the shortest alternative leading, 'OAuth 2' matched
|
|
||||||
# only 'OAuth', so applying the suggestion produced 'OAuth 2.0 2'. The rule is
|
|
||||||
# already case-insensitive, so the inline (?i) is redundant. See issue #41.
|
|
||||||
'\bOauth2\.0\b|\bOAuth ?2\b(?!\.0)|\bOauth\b(?! ?2)': OAuth 2.0
|
|
||||||
"(?:ok|Okay)": OK|okay
|
|
||||||
"(?:WiFi|wifi)": Wi-Fi
|
|
||||||
'[\.]+apk': APK
|
|
||||||
'3\-D': 3D
|
|
||||||
'Google (?:I\-O|IO)': Google I/O
|
|
||||||
"tap (?:&|and) hold": touch & hold
|
|
||||||
"un(?:check|select)": clear
|
|
||||||
above: preceding
|
|
||||||
account name: username
|
|
||||||
action bar: app bar
|
|
||||||
admin: administrator
|
|
||||||
a\.k\.a|aka: or|also known as
|
|
||||||
application: app
|
|
||||||
approx\.: approximately
|
|
||||||
autoupdate: automatically update
|
|
||||||
cellular data: mobile data
|
|
||||||
cellular network: mobile network
|
|
||||||
chapter: documents|pages|sections
|
|
||||||
check box: checkbox
|
|
||||||
click on: click|click in
|
|
||||||
content type: media type
|
|
||||||
curated roles: predefined roles
|
|
||||||
data are: data is
|
|
||||||
disabled?: turn off|off
|
|
||||||
ephemeral IP address: ephemeral external IP address
|
|
||||||
fewer data: less data
|
|
||||||
file name: filename
|
|
||||||
firewalls: firewall rules
|
|
||||||
functionality: capability|feature
|
|
||||||
grayed-out: unavailable
|
|
||||||
in order to: to
|
|
||||||
ingest: import|load
|
|
||||||
long press: touch & hold
|
|
||||||
network IP address: internal IP address
|
|
||||||
omnibox: address bar
|
|
||||||
open-source: open source
|
|
||||||
overview screen: recents screen
|
|
||||||
regex: regular expression
|
|
||||||
sign into: sign in to
|
|
||||||
'(?<!single )sign-?on': single sign-on
|
|
||||||
static IP address: static external IP address
|
|
||||||
stylesheet: style sheet
|
|
||||||
synch: sync
|
|
||||||
tablename: table name
|
|
||||||
tablet: device
|
|
||||||
'touch(?! ?(?:&|and) hold)': tap
|
|
||||||
vs\.: versus
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"feed": "https://github.com/errata-ai/Google/releases.atom",
|
|
||||||
"vale_version": ">=1.0.0"
|
|
||||||
}
|
|
||||||
@@ -141,6 +141,7 @@ src/devx/
|
|||||||
└── molecule/ # Optional molecule testing helpers (for Ansible projects)
|
└── molecule/ # Optional molecule testing helpers (for Ansible projects)
|
||||||
├── discover_runners.py # Dynamic Gitea runner discovery
|
├── discover_runners.py # Dynamic Gitea runner discovery
|
||||||
├── distribute_molecule.py # Distribute molecule scenarios across runners (LPT scheduling, --roles-root for multi-role)
|
├── distribute_molecule.py # Distribute molecule scenarios across runners (LPT scheduling, --roles-root for multi-role)
|
||||||
|
├── molecule_ci_guard.py # Run molecule with cross-runner fail-fast (--roles-root)
|
||||||
├── molecule_all.py # Run all molecule scenarios locally
|
├── molecule_all.py # Run all molecule scenarios locally
|
||||||
├── start_docker.py # Ensure Docker daemon is running for molecule tests
|
├── start_docker.py # Ensure Docker daemon is running for molecule tests
|
||||||
└── platforms.py # Supported molecule platforms
|
└── platforms.py # Supported molecule platforms
|
||||||
|
|||||||
+48
-40
@@ -2,65 +2,73 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
## [0.48.2] - 2026-08-09
|
## [0.49.5] - 2026-08-07
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
- Skip dep resolution in setup-image with --no-deps
|
||||||
|
|
||||||
|
## [0.49.4] - 2026-08-07
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|
||||||
- Remove dead translation keys and add missing one
|
- Add container.credentials for private registry auth
|
||||||
|
|
||||||
## [0.48.1] - 2026-08-08
|
## [0.49.3] - 2026-08-07
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|
||||||
- *(setup)* Extract version from filename for mirror installs
|
- Retry ansible-galaxy collection install on transient timeouts
|
||||||
|
|
||||||
## [0.48.0] - 2026-08-08
|
## [0.49.2] - 2026-08-07
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Add fallback URL for tea download
|
||||||
|
|
||||||
|
## [0.49.1] - 2026-08-07
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Add container images to build-images workflow
|
||||||
|
|
||||||
|
## [0.49.0] - 2026-08-07
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
- *(setup)* Mirror Ansible collections from Gitea registry with auth
|
- Add --include-roles and --exclude-roles to distribute_molecule
|
||||||
|
## [0.48.0] - 2026-07-22
|
||||||
|
|
||||||
## [0.47.10] - 2026-08-05
|
### Features
|
||||||
|
|
||||||
### Bug Fixes
|
- Extract reusable components from infra and grm into devx
|
||||||
|
|
||||||
- Unique molecule container names per CI runner
|
## [0.48.0] - 2026-07-22
|
||||||
|
|
||||||
## [0.47.9] - 2026-08-03
|
### Features
|
||||||
|
|
||||||
### Bug Fixes
|
- Extract reusable components from infra and grm into devx
|
||||||
|
|
||||||
- Unique molecule container names per CI runner
|
## [Unreleased]
|
||||||
|
|
||||||
## [0.47.8] - 2026-08-03
|
### Features
|
||||||
|
|
||||||
### Bug Fixes
|
- Extract reusable components from infra and grm into devx:
|
||||||
|
- `devx.utils.ui.say()` — unified click.echo + logging output
|
||||||
- Increase CI_SCALE_FACTOR default from 4 to 6
|
- `devx.utils.api.APIClient` — base HTTP API client class with retry logic
|
||||||
|
- `devx.utils.jinja` — Jinja2 environment helpers with Ansible-compatible filters
|
||||||
## [0.47.7] - 2026-08-03
|
- `devx.i18n.configure_i18n()` — configurable `lang_env_var` and `translations_path_env_var`
|
||||||
|
- `devx.ci.cancel_superseded_runs` — cancel in-flight CI runs for the same PR branch
|
||||||
### Bug Fixes
|
- `devx.ci.check_workflow_artifact_deps` — verify artifact download jobs depend on upload jobs
|
||||||
|
- `devx.ci.check_workflow_tofu_init` — verify tofu-state jobs have a tofu-init step
|
||||||
- Scale check_test_speed limits on CI runners
|
- `devx.tools.check_docker_init` — check Docker Compose services with healthchecks have init: true
|
||||||
|
- `devx.tools.check_ansible_set_fact_to_json` — check set_fact tasks don't misuse to_json
|
||||||
## [0.47.6] - 2026-08-03
|
- `devx.tools.check_alert_rules` — validate Prometheus alert rules with promtool
|
||||||
|
- Add `jinja2` and `pyyaml` as core dependencies (previously in `deploy` extras only)
|
||||||
### Bug Fixes
|
- Register new CLI commands: `devx ci cancel-superseded-runs`, `devx ci check-workflow-artifact-deps`,
|
||||||
|
`devx ci check-workflow-tofu-init`, `devx tools check-docker-init`,
|
||||||
- Configure git auth in setup_image for git+https deps
|
`devx tools check-ansible-set-fact-to-json`, `devx tools check-alert-rules`
|
||||||
|
- Add Makefile targets for all new check tools
|
||||||
## [0.47.5] - 2026-08-03
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
- Push wiki to main branch instead of master
|
|
||||||
|
|
||||||
## [0.47.4] - 2026-08-03
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
- Add User-Agent header to _download in install_tools
|
|
||||||
|
|
||||||
## [0.47.3] - 2026-07-17
|
## [0.47.3] - 2026-07-17
|
||||||
|
|
||||||
|
|||||||
@@ -226,6 +226,10 @@ python -m devx.molecule.distribute_molecule --runner-index 1 --max-runners 3
|
|||||||
python -m devx.molecule.distribute_molecule --list # list all scenarios
|
python -m devx.molecule.distribute_molecule --list # list all scenarios
|
||||||
python -m devx.molecule.distribute_molecule --list-platforms # list platforms
|
python -m devx.molecule.distribute_molecule --list-platforms # list platforms
|
||||||
|
|
||||||
|
# Run molecule tests with cross-runner fail-fast
|
||||||
|
python -m devx.molecule.molecule_ci_guard pair1 pair2
|
||||||
|
python -m devx.molecule.molecule_ci_guard --roles-root ansible/roles pair1 pair2
|
||||||
|
|
||||||
# Run all molecule scenarios locally (sequential)
|
# Run all molecule scenarios locally (sequential)
|
||||||
python -m devx.molecule.molecule_all
|
python -m devx.molecule.molecule_all
|
||||||
python -m devx.molecule.molecule_all --bin .venv/bin
|
python -m devx.molecule.molecule_all --bin .venv/bin
|
||||||
@@ -299,6 +303,7 @@ devx --version
|
|||||||
| `devx molecule all` | Run all molecule scenarios on all supported platforms |
|
| `devx molecule all` | Run all molecule scenarios on all supported platforms |
|
||||||
| `devx molecule discover-runners` | Discover available Gitea Actions runners |
|
| `devx molecule discover-runners` | Discover available Gitea Actions runners |
|
||||||
| `devx molecule distribute` | Distribute molecule test pairs across parallel runners |
|
| `devx molecule distribute` | Distribute molecule test pairs across parallel runners |
|
||||||
|
| `devx molecule guard` | Run molecule tests with CI failure polling |
|
||||||
|
|
||||||
See [CLI Commands](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki/CLI-Commands)
|
See [CLI Commands](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki/CLI-Commands)
|
||||||
in the wiki for full command documentation with examples.
|
in the wiki for full command documentation with examples.
|
||||||
|
|||||||
+3
-3
@@ -104,8 +104,8 @@ devx is a self-contained Python package under `src/devx/`:
|
|||||||
- **Dev tools** (`devx.tools`) — setup, install_tools, check_test_speed,
|
- **Dev tools** (`devx.tools`) — setup, install_tools, check_test_speed,
|
||||||
configure_repo, generate_badges, generate_cliff_config, install_checkmake
|
configure_repo, generate_badges, generate_cliff_config, install_checkmake
|
||||||
- **Molecule tools** (`devx.molecule`) — Optional, for projects with Ansible
|
- **Molecule tools** (`devx.molecule`) — Optional, for projects with Ansible
|
||||||
roles: distribute_molecule, molecule_all, discover_runners, start_docker,
|
roles: distribute_molecule, molecule_ci_guard, molecule_all, discover_runners,
|
||||||
platforms
|
start_docker, platforms
|
||||||
|
|
||||||
See [Architecture](Architecture) for the full package structure, module
|
See [Architecture](Architecture) for the full package structure, module
|
||||||
descriptions, design principles, and data flow diagrams.
|
descriptions, design principles, and data flow diagrams.
|
||||||
@@ -132,7 +132,7 @@ devx provides a `devx` CLI with three command groups:
|
|||||||
|
|
||||||
- `devx ci <command>` — CI/CD automation (17 commands)
|
- `devx ci <command>` — CI/CD automation (17 commands)
|
||||||
- `devx tools <command>` — Developer tools (9 commands)
|
- `devx tools <command>` — Developer tools (9 commands)
|
||||||
- `devx molecule <command>` — Molecule testing (3 commands, optional)
|
- `devx molecule <command>` — Molecule testing (4 commands, optional)
|
||||||
|
|
||||||
See [CLI Commands](CLI-Commands) for full command documentation with examples.
|
See [CLI Commands](CLI-Commands) for full command documentation with examples.
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ src/devx/
|
|||||||
├── __init__.py
|
├── __init__.py
|
||||||
├── discover_runners.py # Dynamic Gitea runner discovery
|
├── discover_runners.py # Dynamic Gitea runner discovery
|
||||||
├── distribute_molecule.py # Distribute scenarios across runners
|
├── distribute_molecule.py # Distribute scenarios across runners
|
||||||
|
├── molecule_ci_guard.py # Run molecule with cross-runner fail-fast
|
||||||
├── molecule_all.py # Run all molecule scenarios locally
|
├── molecule_all.py # Run all molecule scenarios locally
|
||||||
├── start_docker.py # Ensure Docker is available for molecule
|
├── start_docker.py # Ensure Docker is available for molecule
|
||||||
└── platforms.py # Supported molecule platforms
|
└── platforms.py # Supported molecule platforms
|
||||||
@@ -299,9 +300,9 @@ Distributes files matching a glob pattern across N parallel runners
|
|||||||
|
|
||||||
### `integration_guard.py`
|
### `integration_guard.py`
|
||||||
|
|
||||||
Runs pytest with cross-runner failure detection. A background thread polls
|
Runs pytest with the same cross-runner failure detection mechanism used by
|
||||||
the Gitea API. If any other integration-tests matrix runner reports failure,
|
`molecule_ci_guard`. If any other integration-tests matrix runner reports
|
||||||
the current pytest subprocess is killed and this runner exits early.
|
failure, the current pytest subprocess is killed and this runner exits early.
|
||||||
|
|
||||||
## Developer tools (`devx.tools`)
|
## Developer tools (`devx.tools`)
|
||||||
|
|
||||||
@@ -381,6 +382,13 @@ the supported OS platform matrix. Supports `--roles-root` for multi-role
|
|||||||
repositories, `--list` to list scenarios, and `--list-platforms` to list
|
repositories, `--list` to list scenarios, and `--list-platforms` to list
|
||||||
platforms.
|
platforms.
|
||||||
|
|
||||||
|
### `molecule_ci_guard.py`
|
||||||
|
|
||||||
|
Runs molecule tests sequentially while polling the Gitea API for other runner
|
||||||
|
failures. If any other molecule matrix runner reports failure, the current
|
||||||
|
molecule subprocess is killed and this runner exits early. Supports both
|
||||||
|
single-role (4-part) and multi-role (5-part) pair encoding.
|
||||||
|
|
||||||
### `molecule_all.py`
|
### `molecule_all.py`
|
||||||
|
|
||||||
Runs all molecule scenarios on all supported OS platforms sequentially.
|
Runs all molecule scenarios on all supported OS platforms sequentially.
|
||||||
|
|||||||
@@ -478,6 +478,15 @@ python -m devx.molecule.distribute_molecule --list
|
|||||||
python -m devx.molecule.distribute_molecule --list-platforms
|
python -m devx.molecule.distribute_molecule --list-platforms
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### `molecule_ci_guard.py`
|
||||||
|
|
||||||
|
Runs molecule tests sequentially while polling the Gitea API for other runner
|
||||||
|
failures. Aborts early if another runner fails the same job.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m devx.molecule.molecule_ci_guard [--roles-root <dir>] pair1 pair2 ...
|
||||||
|
```
|
||||||
|
|
||||||
### `validate_commit_msg.py`
|
### `validate_commit_msg.py`
|
||||||
|
|
||||||
Validates commit messages. On feature branches: conventional commits only
|
Validates commit messages. On feature branches: conventional commits only
|
||||||
|
|||||||
@@ -631,3 +631,29 @@ Options:
|
|||||||
- `--list-platforms` — list all platforms, one per line
|
- `--list-platforms` — list all platforms, one per line
|
||||||
- `--roles-root <dir>` — roles root directory for multi-role repos (default:
|
- `--roles-root <dir>` — roles root directory for multi-role repos (default:
|
||||||
`ansible/roles`)
|
`ansible/roles`)
|
||||||
|
|
||||||
|
### `devx molecule guard`
|
||||||
|
|
||||||
|
Run molecule tests sequentially with CI failure polling. A background thread
|
||||||
|
polls the Gitea API. If any other molecule matrix runner reports failure, the
|
||||||
|
current molecule subprocess is killed and this runner exits early with code 1.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
devx molecule guard pair1 pair2 pair3
|
||||||
|
devx molecule guard --roles-root ansible/roles pair1 pair2
|
||||||
|
```
|
||||||
|
|
||||||
|
Each pair is encoded as:
|
||||||
|
- **Single-role (4-part):** `scenario|platform_name|platform_image|platform_command`
|
||||||
|
- **Multi-role (5-part):** `role|scenario|platform_name|platform_image|platform_command`
|
||||||
|
|
||||||
|
Options:
|
||||||
|
- `--roles-root <dir>` — roles root directory for multi-role repos
|
||||||
|
|
||||||
|
Environment variables:
|
||||||
|
- `GITEA_URL` — base URL of the Gitea instance
|
||||||
|
- `CI_GITEA_TOKEN` — API token with repo access
|
||||||
|
- `RUN_ID` — workflow run ID (`GITHUB_RUN_ID`)
|
||||||
|
- `JOB_NAME` — base job name (`GITHUB_JOB`)
|
||||||
|
- `MATRIX_INDEX` — current matrix index (runner-index)
|
||||||
|
- `GITEA_REPOSITORY` — repository in `owner/repo` format
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ REQUIRED_SCRIPTS = [
|
|||||||
"detect_release_commit.py",
|
"detect_release_commit.py",
|
||||||
"push_badges.py",
|
"push_badges.py",
|
||||||
"distribute_molecule.py",
|
"distribute_molecule.py",
|
||||||
|
"molecule_ci_guard.py",
|
||||||
"validate_commit_msg.py",
|
"validate_commit_msg.py",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Run integration tests with cross-runner failure detection.
|
"""Run integration tests with cross-runner failure detection.
|
||||||
|
|
||||||
Wraps ``pytest`` with Gitea API polling. If any other integration-tests
|
Wraps ``pytest`` with the same Gitea API polling mechanism used by
|
||||||
matrix runner reports failure, the current pytest subprocess is killed
|
``molecule_ci_guard``. If any other integration-tests matrix runner
|
||||||
and this runner exits early with code 1.
|
reports failure, the current pytest subprocess is killed and this runner
|
||||||
|
exits early with code 1.
|
||||||
|
|
||||||
Usage::
|
Usage::
|
||||||
|
|
||||||
@@ -34,62 +35,17 @@ import threading
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
import click
|
import click
|
||||||
import requests
|
|
||||||
|
|
||||||
from devx.config import REPO_NAME, REPO_OWNER
|
from devx.config import REPO_NAME, REPO_OWNER
|
||||||
from devx.i18n import _
|
from devx.i18n import _
|
||||||
|
from devx.molecule.molecule_ci_guard import (
|
||||||
|
poll_for_other_failures,
|
||||||
|
)
|
||||||
from devx.tokens import get_ci_token
|
from devx.tokens import get_ci_token
|
||||||
|
|
||||||
POLL_INTERVAL = 10
|
POLL_INTERVAL = 10
|
||||||
|
|
||||||
|
|
||||||
def get_running_jobs(gitea_url: str, owner: str, repo: str, token: str, run_id: int) -> list[dict]:
|
|
||||||
"""Return jobs for the given workflow run."""
|
|
||||||
url = f"{gitea_url}/api/v1/repos/{owner}/{repo}/actions/runs/{run_id}/jobs"
|
|
||||||
headers = {"Authorization": f"token {token}"}
|
|
||||||
response = requests.get(url, headers=headers, timeout=30)
|
|
||||||
response.raise_for_status()
|
|
||||||
data = response.json()
|
|
||||||
return data.get("jobs", [])
|
|
||||||
|
|
||||||
|
|
||||||
def any_other_runner_failed(jobs: list[dict], current_job_name: str, current_index: int) -> bool:
|
|
||||||
"""Return True if any other matrix job has failed."""
|
|
||||||
for job in jobs:
|
|
||||||
name = job.get("name", "")
|
|
||||||
if not name.startswith(current_job_name):
|
|
||||||
continue
|
|
||||||
if name == f"{current_job_name} ({current_index})" or name == current_job_name:
|
|
||||||
continue
|
|
||||||
if job.get("conclusion") == "failure":
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def poll_for_other_failures(
|
|
||||||
gitea_url: str,
|
|
||||||
owner: str,
|
|
||||||
repo: str,
|
|
||||||
token: str,
|
|
||||||
run_id: int,
|
|
||||||
job_name: str,
|
|
||||||
current_index: int,
|
|
||||||
stop_event: threading.Event,
|
|
||||||
failed_event: threading.Event,
|
|
||||||
) -> None:
|
|
||||||
"""Background thread: poll API and signal if another runner fails."""
|
|
||||||
while not stop_event.is_set():
|
|
||||||
try:
|
|
||||||
jobs = get_running_jobs(gitea_url, owner, repo, token, run_id)
|
|
||||||
if any_other_runner_failed(jobs, job_name, current_index):
|
|
||||||
click.echo(_("Another runner failed. Stopping this runner early."))
|
|
||||||
failed_event.set()
|
|
||||||
return
|
|
||||||
except requests.RequestException as exc:
|
|
||||||
click.echo(_("API poll warning: {exc}", exc=exc))
|
|
||||||
stop_event.wait(POLL_INTERVAL)
|
|
||||||
|
|
||||||
|
|
||||||
@click.command(context_settings={"ignore_unknown_options": True})
|
@click.command(context_settings={"ignore_unknown_options": True})
|
||||||
@click.argument("pytest_args", nargs=-1, type=click.UNPROCESSED, required=True)
|
@click.argument("pytest_args", nargs=-1, type=click.UNPROCESSED, required=True)
|
||||||
def cli(pytest_args: tuple[str, ...]) -> None:
|
def cli(pytest_args: tuple[str, ...]) -> None:
|
||||||
|
|||||||
@@ -301,6 +301,13 @@ def molecule_discover_runners(args: tuple[str, ...]) -> None:
|
|||||||
_run_module("devx.molecule.discover_runners", list(args))
|
_run_module("devx.molecule.discover_runners", list(args))
|
||||||
|
|
||||||
|
|
||||||
|
@molecule.command("guard")
|
||||||
|
@click.argument("args", nargs=-1)
|
||||||
|
def molecule_guard(args: tuple[str, ...]) -> None:
|
||||||
|
"""Run molecule tests sequentially with CI failure polling."""
|
||||||
|
_run_module("devx.molecule.molecule_ci_guard", list(args))
|
||||||
|
|
||||||
|
|
||||||
@molecule.command("all")
|
@molecule.command("all")
|
||||||
@click.argument("args", nargs=-1)
|
@click.argument("args", nargs=-1)
|
||||||
def molecule_all(args: tuple[str, ...]) -> None:
|
def molecule_all(args: tuple[str, ...]) -> None:
|
||||||
|
|||||||
@@ -0,0 +1,333 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Run molecule tests sequentially while polling Gitea for other runner failures.
|
||||||
|
|
||||||
|
Each pair is encoded as one of:
|
||||||
|
|
||||||
|
- **Single-role (4-part):** ``scenario|platform_name|platform_image|platform_command``
|
||||||
|
- **Multi-role (5-part):** ``role|scenario|platform_name|platform_image|platform_command``
|
||||||
|
|
||||||
|
Pairs are executed one at a time (molecule scenarios share temp directories and
|
||||||
|
Docker networks, so parallel execution within a single runner is unsafe).
|
||||||
|
|
||||||
|
A background thread polls the Gitea API. If any other molecule matrix runner
|
||||||
|
reports failure, the current molecule subprocess is killed and this runner
|
||||||
|
exits early with code 1.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
# Single-role
|
||||||
|
python3 -m devx.molecule.molecule_ci_guard pair1 pair2 ...
|
||||||
|
# Multi-role
|
||||||
|
python3 -m devx.molecule.molecule_ci_guard --roles-root ansible/roles pair1 pair2 ...
|
||||||
|
|
||||||
|
Environment variables:
|
||||||
|
GITEA_URL Base URL of the Gitea instance.
|
||||||
|
CI_GITEA_API_TOKEN API token with repo access (CI_GITEA_TOKEN accepted for legacy).
|
||||||
|
RUN_ID Workflow run ID (GITHUB_RUN_ID).
|
||||||
|
JOB_NAME Base job name (GITHUB_JOB), e.g. "molecule-tests".
|
||||||
|
MATRIX_INDEX Current matrix index (runner-index).
|
||||||
|
GITEA_REPOSITORY Repository in "owner/repo" format.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import subprocess # nosec B404
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import click
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from devx.config import REPO_NAME, REPO_OWNER
|
||||||
|
from devx.i18n import _
|
||||||
|
from devx.tokens import get_ci_token
|
||||||
|
|
||||||
|
POLL_INTERVAL = 10
|
||||||
|
|
||||||
|
|
||||||
|
def get_running_jobs(gitea_url: str, owner: str, repo: str, token: str, run_id: int) -> list[dict]:
|
||||||
|
"""Return jobs for the given workflow run."""
|
||||||
|
url = f"{gitea_url}/api/v1/repos/{owner}/{repo}/actions/runs/{run_id}/jobs"
|
||||||
|
headers = {"Authorization": f"token {token}"}
|
||||||
|
response = requests.get(url, headers=headers, timeout=30)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
return data.get("jobs", [])
|
||||||
|
|
||||||
|
|
||||||
|
def any_other_runner_failed(jobs: list[dict], current_job_name: str, current_index: int) -> bool:
|
||||||
|
"""Return True if any other molecule matrix job has failed."""
|
||||||
|
for job in jobs:
|
||||||
|
name = job.get("name", "")
|
||||||
|
if not name.startswith(current_job_name):
|
||||||
|
continue
|
||||||
|
if name == f"{current_job_name} ({current_index})" or name == current_job_name:
|
||||||
|
continue
|
||||||
|
if job.get("conclusion") == "failure":
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def poll_for_other_failures(
|
||||||
|
gitea_url: str,
|
||||||
|
owner: str,
|
||||||
|
repo: str,
|
||||||
|
token: str,
|
||||||
|
run_id: int,
|
||||||
|
job_name: str,
|
||||||
|
current_index: int,
|
||||||
|
stop_event: threading.Event,
|
||||||
|
failed_event: threading.Event,
|
||||||
|
) -> None:
|
||||||
|
"""Background thread: poll API and signal if another runner fails."""
|
||||||
|
while not stop_event.is_set():
|
||||||
|
try:
|
||||||
|
jobs = get_running_jobs(gitea_url, owner, repo, token, run_id)
|
||||||
|
if any_other_runner_failed(jobs, job_name, current_index):
|
||||||
|
click.echo(_("Another molecule runner failed. Stopping this runner early."))
|
||||||
|
failed_event.set()
|
||||||
|
return
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
click.echo(_("API poll warning: {exc}", exc=exc))
|
||||||
|
stop_event.wait(POLL_INTERVAL)
|
||||||
|
|
||||||
|
|
||||||
|
def build_molecule_cmd(scenario: str) -> list[str]:
|
||||||
|
"""Build the molecule command for a scenario."""
|
||||||
|
cmd = ["molecule", "test"]
|
||||||
|
if scenario != "default":
|
||||||
|
cmd.extend(["-s", scenario])
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
|
||||||
|
def parse_pair(pair: str) -> tuple[str, str, str, str, str]:
|
||||||
|
"""Parse a pair string into (role, scenario, platform_name, platform_image, platform_command).
|
||||||
|
|
||||||
|
Supports both 4-part (single-role) and 5-part (multi-role) formats.
|
||||||
|
For 4-part pairs, role is empty (caller uses default role dir).
|
||||||
|
Spaces in the command field are encoded as ``__SPACE__`` to survive
|
||||||
|
shell word-splitting when ``$TEST_PAIRS`` is expanded unquoted.
|
||||||
|
"""
|
||||||
|
parts = pair.split("|")
|
||||||
|
if len(parts) == 4:
|
||||||
|
return "", parts[0], parts[1], parts[2], parts[3].replace("__SPACE__", " ")
|
||||||
|
if len(parts) == 5:
|
||||||
|
return parts[0], parts[1], parts[2], parts[3], parts[4].replace("__SPACE__", " ")
|
||||||
|
raise click.ClickException(f"Invalid pair format: {pair!r} (expected 4 or 5 pipe-delimited parts)")
|
||||||
|
|
||||||
|
|
||||||
|
def build_env_for_pair(pair: str, base_env: dict[str, str]) -> dict[str, str]:
|
||||||
|
"""Build environment for a single molecule pair."""
|
||||||
|
_role, _scenario, platform_name, platform_image, platform_command = parse_pair(pair)
|
||||||
|
env = base_env.copy()
|
||||||
|
# Append runner index to platform name when running in CI matrix to avoid
|
||||||
|
# Docker container name conflicts when multiple runners share the same Docker host.
|
||||||
|
matrix_index = env.get("MATRIX_INDEX")
|
||||||
|
if matrix_index:
|
||||||
|
platform_name = f"{platform_name}-r{matrix_index}"
|
||||||
|
env["MOLECULE_PLATFORM_NAME"] = platform_name
|
||||||
|
env["MOLECULE_PLATFORM_IMAGE"] = platform_image
|
||||||
|
if platform_command:
|
||||||
|
env["MOLECULE_PLATFORM_COMMAND"] = platform_command
|
||||||
|
elif "MOLECULE_PLATFORM_COMMAND" in env:
|
||||||
|
del env["MOLECULE_PLATFORM_COMMAND"]
|
||||||
|
env["ANSIBLE_ALLOW_BROKEN_CONDITIONALS"] = "true"
|
||||||
|
# Use a fresh MOLECULE_HOME per pair to avoid stale config cache
|
||||||
|
# from previous CI runs (causes "Instances missing" errors).
|
||||||
|
if "MOLECULE_HOME" not in env:
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
env["MOLECULE_HOME"] = tempfile.mkdtemp(prefix="molecule-ci-")
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_role_dir(role: str, roles_root: Path | None, repo_root: Path) -> Path:
|
||||||
|
"""Resolve the working directory for a molecule pair.
|
||||||
|
|
||||||
|
For multi-role pairs (role non-empty), uses ``roles_root/role``.
|
||||||
|
For single-role pairs, auto-discovers the first role with a molecule/
|
||||||
|
subdirectory under ``repo_root/ansible/roles/``.
|
||||||
|
"""
|
||||||
|
if role:
|
||||||
|
if roles_root is None:
|
||||||
|
roles_root = repo_root / "ansible" / "roles"
|
||||||
|
return roles_root / role
|
||||||
|
roles_dir = repo_root / "ansible" / "roles"
|
||||||
|
if roles_dir.is_dir():
|
||||||
|
role_dirs = sorted(d for d in roles_dir.iterdir() if (d / "molecule").is_dir())
|
||||||
|
if role_dirs:
|
||||||
|
return role_dirs[0]
|
||||||
|
return roles_dir / "role" # will produce a clear "not found" error
|
||||||
|
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
@click.argument("pairs", nargs=-1, required=True)
|
||||||
|
@click.option(
|
||||||
|
"--roles-root",
|
||||||
|
type=click.Path(exists=True, file_okay=False, path_type=Path),
|
||||||
|
default=None,
|
||||||
|
help="Root directory for multi-role pairs (e.g. ansible/roles). Required when pairs use 5-part format.",
|
||||||
|
)
|
||||||
|
def cli(pairs: tuple[str, ...], roles_root: Path | None) -> None:
|
||||||
|
"""Run molecule pairs sequentially, stop if another CI runner fails."""
|
||||||
|
gitea_url = os.environ.get("GITEA_URL", "")
|
||||||
|
try:
|
||||||
|
token = get_ci_token()
|
||||||
|
except click.ClickException:
|
||||||
|
token = None
|
||||||
|
run_id = int(os.environ.get("RUN_ID", "0"))
|
||||||
|
job_name = os.environ.get("JOB_NAME", "molecule-tests")
|
||||||
|
current_index = int(os.environ.get("MATRIX_INDEX", "0"))
|
||||||
|
repository = os.environ.get("GITEA_REPOSITORY", "")
|
||||||
|
owner, _sep, repo = repository.partition("/")
|
||||||
|
if not owner or not repo:
|
||||||
|
owner, repo = REPO_OWNER, REPO_NAME
|
||||||
|
|
||||||
|
if not all([gitea_url, token, run_id]):
|
||||||
|
click.echo(_("GITEA_URL/CI_GITEA_TOKEN/RUN_ID not set; running without cross-runner cancellation."))
|
||||||
|
|
||||||
|
# When devx is installed as a pip package, __file__ resolves to the
|
||||||
|
# site-packages directory, not the repo root. Use GITHUB_WORKSPACE
|
||||||
|
# (set by Gitea Actions) or cwd as the repo root.
|
||||||
|
repo_root = Path(os.environ.get("GITHUB_WORKSPACE", os.getcwd())).resolve()
|
||||||
|
|
||||||
|
base_env = os.environ.copy()
|
||||||
|
base_env.setdefault("DOCKER_HOST", f"unix:///run/user/{os.getuid()}/docker.sock")
|
||||||
|
base_env.setdefault("ANSIBLE_INJECT_INVOCATION", "1")
|
||||||
|
|
||||||
|
stop_event = threading.Event()
|
||||||
|
failed_event = threading.Event()
|
||||||
|
|
||||||
|
if gitea_url and token and run_id:
|
||||||
|
poller = threading.Thread(
|
||||||
|
target=poll_for_other_failures,
|
||||||
|
args=(
|
||||||
|
gitea_url,
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
token,
|
||||||
|
run_id,
|
||||||
|
job_name,
|
||||||
|
current_index,
|
||||||
|
stop_event,
|
||||||
|
failed_event,
|
||||||
|
),
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
poller.start()
|
||||||
|
|
||||||
|
try:
|
||||||
|
for pair in pairs:
|
||||||
|
if failed_event.is_set():
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
role, scenario, platform_name, _img, _cmd = parse_pair(pair)
|
||||||
|
click.echo(_("Running: {scenario} on {platform}", scenario=scenario, platform=platform_name))
|
||||||
|
|
||||||
|
cmd = build_molecule_cmd(scenario)
|
||||||
|
env = build_env_for_pair(pair, base_env)
|
||||||
|
cwd = resolve_role_dir(role, roles_root, repo_root)
|
||||||
|
|
||||||
|
process = subprocess.Popen( # nosec B603
|
||||||
|
cmd,
|
||||||
|
cwd=str(cwd),
|
||||||
|
env=env,
|
||||||
|
preexec_fn=os.setsid,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
while process.poll() is None:
|
||||||
|
if failed_event.is_set():
|
||||||
|
with contextlib.suppress(ProcessLookupError):
|
||||||
|
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
|
||||||
|
try:
|
||||||
|
process.wait(timeout=10)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
with contextlib.suppress(ProcessLookupError):
|
||||||
|
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
|
||||||
|
process.wait()
|
||||||
|
# Clean up containers left behind by the killed test.
|
||||||
|
click.echo(_("Cleaning up: running molecule destroy for {scenario}", scenario=scenario))
|
||||||
|
destroy_cmd = ["molecule", "destroy"]
|
||||||
|
if scenario != "default":
|
||||||
|
destroy_cmd.extend(["-s", scenario])
|
||||||
|
with contextlib.suppress(subprocess.SubprocessError, OSError):
|
||||||
|
subprocess.run( # nosec B603, B607
|
||||||
|
destroy_cmd,
|
||||||
|
cwd=str(cwd),
|
||||||
|
env=env,
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
timeout=120,
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
time.sleep(1)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
with contextlib.suppress(ProcessLookupError):
|
||||||
|
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
|
||||||
|
process.wait()
|
||||||
|
# Clean up containers left behind by the interrupted test.
|
||||||
|
click.echo(_("Cleaning up: running molecule destroy for {scenario}", scenario=scenario))
|
||||||
|
destroy_cmd = ["molecule", "destroy"]
|
||||||
|
if scenario != "default":
|
||||||
|
destroy_cmd.extend(["-s", scenario])
|
||||||
|
with contextlib.suppress(subprocess.SubprocessError, OSError):
|
||||||
|
subprocess.run( # nosec B603, B607
|
||||||
|
destroy_cmd,
|
||||||
|
cwd=str(cwd),
|
||||||
|
env=env,
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
timeout=120,
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
rc = process.returncode
|
||||||
|
|
||||||
|
if rc != 0:
|
||||||
|
click.echo(_("FAILED: {pair} exited with code {code}", pair=pair, code=rc))
|
||||||
|
# Run molecule destroy to clean up containers left behind by the
|
||||||
|
# failed test. Without this, containers stay running and accumulate
|
||||||
|
# on the runner, consuming disk/memory and degrading CI performance.
|
||||||
|
click.echo(_("Cleaning up: running molecule destroy for {scenario}", scenario=scenario))
|
||||||
|
destroy_cmd = ["molecule", "destroy"]
|
||||||
|
if scenario != "default":
|
||||||
|
destroy_cmd.extend(["-s", scenario])
|
||||||
|
with contextlib.suppress(subprocess.SubprocessError, OSError):
|
||||||
|
subprocess.run( # nosec B603, B607
|
||||||
|
destroy_cmd,
|
||||||
|
cwd=str(cwd),
|
||||||
|
env=env,
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
timeout=120,
|
||||||
|
)
|
||||||
|
sys.exit(rc)
|
||||||
|
|
||||||
|
click.echo(_("PASSED: {pair}", pair=pair))
|
||||||
|
|
||||||
|
# Prune Docker data between scenarios to prevent disk exhaustion
|
||||||
|
# in Docker-in-Docker molecule containers (each scenario pulls
|
||||||
|
# hundreds of MB of images that accumulate across pairs).
|
||||||
|
with contextlib.suppress(subprocess.SubprocessError, OSError):
|
||||||
|
subprocess.run( # nosec B603, B607
|
||||||
|
["docker", "system", "prune", "-af", "--volumes"],
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
|
||||||
|
click.echo(_("All molecule tests passed."))
|
||||||
|
finally:
|
||||||
|
stop_event.set()
|
||||||
|
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
cli()
|
||||||
@@ -783,6 +783,14 @@
|
|||||||
"ru": "Additional directory to scan (default: scripts, tests). Can be repeated.",
|
"ru": "Additional directory to scan (default: scripts, tests). Can be repeated.",
|
||||||
"zh": "Additional directory to scan (default: scripts, tests). Can be repeated."
|
"zh": "Additional directory to scan (default: scripts, tests). Can be repeated."
|
||||||
},
|
},
|
||||||
|
"All molecule tests passed.": {
|
||||||
|
"bg": "All molecule tests passed.",
|
||||||
|
"de": "All molecule tests passed.",
|
||||||
|
"en": "All molecule tests passed.",
|
||||||
|
"pl": "Wszystkie testy molecule zakończone pomyślnie.",
|
||||||
|
"ru": "All molecule tests passed.",
|
||||||
|
"zh": "All molecule tests passed."
|
||||||
|
},
|
||||||
"Allow empty tag (PR mode where SHA is concrete).": {
|
"Allow empty tag (PR mode where SHA is concrete).": {
|
||||||
"bg": "Позволи празен таг (PR режим, където SHA е конкретен).",
|
"bg": "Позволи празен таг (PR режим, където SHA е конкретен).",
|
||||||
"de": "Leeren Tag zulassen (PR-Modus, in dem SHA konkret ist).",
|
"de": "Leeren Tag zulassen (PR-Modus, in dem SHA konkret ist).",
|
||||||
@@ -791,13 +799,13 @@
|
|||||||
"ru": "Разрешить пустой тег (режим PR, где SHA конкретен).",
|
"ru": "Разрешить пустой тег (режим PR, где SHA конкретен).",
|
||||||
"zh": "允许空标签(SHA 为具体值的 PR 模式)。"
|
"zh": "允许空标签(SHA 为具体值的 PR 模式)。"
|
||||||
},
|
},
|
||||||
"Another runner failed. Stopping this runner early.": {
|
"Another molecule runner failed. Stopping this runner early.": {
|
||||||
"bg": "Друг runner се провали. Спиране на този runner по-рано.",
|
"bg": "Another molecule runner failed. Stopping this runner early.",
|
||||||
"de": "Ein anderer Runner ist fehlgeschlagen. Dieser Runner wird vorzeitig gestoppt.",
|
"de": "Another molecule runner failed. Stopping this runner early.",
|
||||||
"en": "Another runner failed. Stopping this runner early.",
|
"en": "Another molecule runner failed. Stopping this runner early.",
|
||||||
"pl": "Inny runner zakończył się niepowodzeniem. Wczesne zatrzymanie tego runnera.",
|
"pl": "Inny runner molecule zakończył się niepowodzeniem. Wczesne zatrzymanie tego runnera.",
|
||||||
"ru": "Другой runner завершился с ошибкой. Останавливаю этот runner досрочно.",
|
"ru": "Another molecule runner failed. Stopping this runner early.",
|
||||||
"zh": "另一个 runner 失败。提前停止此 runner。"
|
"zh": "Another molecule runner failed. Stopping this runner early."
|
||||||
},
|
},
|
||||||
"Assigned {count} files to runner {runner_index}": {
|
"Assigned {count} files to runner {runner_index}": {
|
||||||
"bg": "Assigned {count} files to runner {runner_index}",
|
"bg": "Assigned {count} files to runner {runner_index}",
|
||||||
@@ -1487,6 +1495,14 @@
|
|||||||
"ru": "FAILED: {count} undocumented dependency/ies",
|
"ru": "FAILED: {count} undocumented dependency/ies",
|
||||||
"zh": "FAILED: {count} undocumented dependency/ies"
|
"zh": "FAILED: {count} undocumented dependency/ies"
|
||||||
},
|
},
|
||||||
|
"FAILED: {pair} exited with code {code}": {
|
||||||
|
"bg": "FAILED: {pair} exited with code {code}",
|
||||||
|
"de": "FAILED: {pair} exited with code {code}",
|
||||||
|
"en": "FAILED: {pair} exited with code {code}",
|
||||||
|
"pl": "NIEUDANE: {pair} zakończone kodem {code}",
|
||||||
|
"ru": "FAILED: {pair} exited with code {code}",
|
||||||
|
"zh": "FAILED: {pair} exited with code {code}"
|
||||||
|
},
|
||||||
"Failed images: {names}": {
|
"Failed images: {names}": {
|
||||||
"bg": "Failed images: {names}",
|
"bg": "Failed images: {names}",
|
||||||
"de": "Failed images: {names}",
|
"de": "Failed images: {names}",
|
||||||
@@ -2247,6 +2263,14 @@
|
|||||||
"ru": "PASS: All documentation checks passed!",
|
"ru": "PASS: All documentation checks passed!",
|
||||||
"zh": "PASS: All documentation checks passed!"
|
"zh": "PASS: All documentation checks passed!"
|
||||||
},
|
},
|
||||||
|
"PASSED: {pair}": {
|
||||||
|
"bg": "PASSED: {pair}",
|
||||||
|
"de": "PASSED: {pair}",
|
||||||
|
"en": "PASSED: {pair}",
|
||||||
|
"pl": "UDANE: {pair}",
|
||||||
|
"ru": "PASSED: {pair}",
|
||||||
|
"zh": "PASSED: {pair}"
|
||||||
|
},
|
||||||
"PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR.": {
|
"PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR.": {
|
||||||
"bg": "PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR.",
|
"bg": "PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR.",
|
||||||
"de": "PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR.",
|
"de": "PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR.",
|
||||||
@@ -2719,6 +2743,14 @@
|
|||||||
"ru": "Running: {cmd}",
|
"ru": "Running: {cmd}",
|
||||||
"zh": "Running: {cmd}"
|
"zh": "Running: {cmd}"
|
||||||
},
|
},
|
||||||
|
"Running: {scenario} on {platform}": {
|
||||||
|
"bg": "Running: {scenario} on {platform}",
|
||||||
|
"de": "Running: {scenario} on {platform}",
|
||||||
|
"en": "Running: {scenario} on {platform}",
|
||||||
|
"pl": "Uruchamianie: {scenario} na {platform}",
|
||||||
|
"ru": "Running: {scenario} on {platform}",
|
||||||
|
"zh": "Running: {scenario} on {platform}"
|
||||||
|
},
|
||||||
"SSH key set up successfully": {
|
"SSH key set up successfully": {
|
||||||
"bg": "SSH ключът е настроен успешно",
|
"bg": "SSH ключът е настроен успешно",
|
||||||
"de": "SSH-Schlüssel erfolgreich eingerichtet",
|
"de": "SSH-Schlüssel erfolgreich eingerichtet",
|
||||||
@@ -3798,5 +3830,13 @@
|
|||||||
"pl": "[check-test-speed] CI environment detected — scaling limits by {factor}x (total: {orig}s → {eff}s, per-test: {orig_s}s → {eff_s}s)",
|
"pl": "[check-test-speed] CI environment detected — scaling limits by {factor}x (total: {orig}s → {eff}s, per-test: {orig_s}s → {eff_s}s)",
|
||||||
"ru": "[check-test-speed] CI environment detected — scaling limits by {factor}x (total: {orig}s → {eff}s, per-test: {orig_s}s → {eff_s}s)",
|
"ru": "[check-test-speed] CI environment detected — scaling limits by {factor}x (total: {orig}s → {eff}s, per-test: {orig_s}s → {eff_s}s)",
|
||||||
"zh": "[check-test-speed] CI environment detected — scaling limits by {factor}x (total: {orig}s → {eff}s, per-test: {orig_s}s → {eff_s}s)"
|
"zh": "[check-test-speed] CI environment detected — scaling limits by {factor}x (total: {orig}s → {eff}s, per-test: {orig_s}s → {eff_s}s)"
|
||||||
|
},
|
||||||
|
"Cleaning up: running molecule destroy for {scenario}": {
|
||||||
|
"en": "Cleaning up: running molecule destroy for {scenario}",
|
||||||
|
"bg": "Изчистване: изпълнение на molecule destroy за {scenario}",
|
||||||
|
"de": "Aufräumen: molecule destroy wird ausgeführt für {scenario}",
|
||||||
|
"pl": "Czyszczenie: uruchamianie molecule destroy dla {scenario}",
|
||||||
|
"ru": "Очистка: запуск molecule destroy для {scenario}",
|
||||||
|
"zh": "清理:正在为 {scenario} 运行 molecule destroy"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ class TestCliGroups:
|
|||||||
result = runner.invoke(cli, ["molecule", "--help"])
|
result = runner.invoke(cli, ["molecule", "--help"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "distribute" in result.output
|
assert "distribute" in result.output
|
||||||
|
assert "guard" in result.output
|
||||||
assert "all" in result.output
|
assert "all" in result.output
|
||||||
|
|
||||||
|
|
||||||
@@ -230,6 +231,13 @@ class TestMoleculeCommands:
|
|||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
mock_run.assert_called_once_with("devx.molecule.discover_runners", [])
|
mock_run.assert_called_once_with("devx.molecule.discover_runners", [])
|
||||||
|
|
||||||
|
@patch("devx.cli._run_module")
|
||||||
|
def test_molecule_guard(self, mock_run: MagicMock) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(cli, ["molecule", "guard"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
mock_run.assert_called_once_with("devx.molecule.molecule_ci_guard", [])
|
||||||
|
|
||||||
@patch("devx.cli._run_module")
|
@patch("devx.cli._run_module")
|
||||||
def test_molecule_all(self, mock_run: MagicMock) -> None:
|
def test_molecule_all(self, mock_run: MagicMock) -> None:
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import subprocess # nosec B404
|
|||||||
import time
|
import time
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
|
||||||
from click.testing import CliRunner
|
from click.testing import CliRunner
|
||||||
|
|
||||||
from devx.ci.integration_guard import cli
|
from devx.ci.integration_guard import cli
|
||||||
@@ -112,8 +111,9 @@ class TestCli:
|
|||||||
clear=True,
|
clear=True,
|
||||||
),
|
),
|
||||||
patch("devx.ci.integration_guard.POLL_INTERVAL", 0.01),
|
patch("devx.ci.integration_guard.POLL_INTERVAL", 0.01),
|
||||||
|
patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01),
|
||||||
patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen,
|
patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen,
|
||||||
patch("devx.ci.integration_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
||||||
patch("os.killpg") as mock_killpg,
|
patch("os.killpg") as mock_killpg,
|
||||||
patch("os.getpgid") as mock_getpgid,
|
patch("os.getpgid") as mock_getpgid,
|
||||||
patch("time.sleep", side_effect=lambda x: real_sleep(0)),
|
patch("time.sleep", side_effect=lambda x: real_sleep(0)),
|
||||||
@@ -158,8 +158,9 @@ class TestCli:
|
|||||||
clear=True,
|
clear=True,
|
||||||
),
|
),
|
||||||
patch("devx.ci.integration_guard.POLL_INTERVAL", 0.01),
|
patch("devx.ci.integration_guard.POLL_INTERVAL", 0.01),
|
||||||
|
patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01),
|
||||||
patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen,
|
patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen,
|
||||||
patch("devx.ci.integration_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
||||||
patch("os.killpg", side_effect=ProcessLookupError("no such process")),
|
patch("os.killpg", side_effect=ProcessLookupError("no such process")),
|
||||||
patch("os.getpgid") as mock_getpgid,
|
patch("os.getpgid") as mock_getpgid,
|
||||||
patch("time.sleep", side_effect=lambda x: real_sleep(0)),
|
patch("time.sleep", side_effect=lambda x: real_sleep(0)),
|
||||||
@@ -202,8 +203,9 @@ class TestCli:
|
|||||||
clear=True,
|
clear=True,
|
||||||
),
|
),
|
||||||
patch("devx.ci.integration_guard.POLL_INTERVAL", 0.01),
|
patch("devx.ci.integration_guard.POLL_INTERVAL", 0.01),
|
||||||
|
patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01),
|
||||||
patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen,
|
patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen,
|
||||||
patch("devx.ci.integration_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
||||||
patch("os.killpg") as mock_killpg,
|
patch("os.killpg") as mock_killpg,
|
||||||
patch("os.getpgid") as mock_getpgid,
|
patch("os.getpgid") as mock_getpgid,
|
||||||
patch("time.sleep", side_effect=lambda x: real_sleep(0)),
|
patch("time.sleep", side_effect=lambda x: real_sleep(0)),
|
||||||
@@ -287,125 +289,3 @@ def test_main_module_block() -> None:
|
|||||||
namespace = dict(ig.__dict__)
|
namespace = dict(ig.__dict__)
|
||||||
exec(compile(source, ig.__file__, "exec"), namespace)
|
exec(compile(source, ig.__file__, "exec"), namespace)
|
||||||
assert callable(namespace["cli"])
|
assert callable(namespace["cli"])
|
||||||
|
|
||||||
|
|
||||||
class TestGetRunningJobs:
|
|
||||||
def test_returns_jobs(self) -> None:
|
|
||||||
with patch("devx.ci.integration_guard.requests.get") as mock_get:
|
|
||||||
mock_response = MagicMock()
|
|
||||||
mock_response.json.return_value = {
|
|
||||||
"jobs": [
|
|
||||||
{"name": "integration-tests (0)", "conclusion": "success"},
|
|
||||||
{"name": "integration-tests (1)", "conclusion": "failure"},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
mock_response.raise_for_status.return_value = None
|
|
||||||
mock_get.return_value = mock_response
|
|
||||||
|
|
||||||
from devx.ci.integration_guard import get_running_jobs
|
|
||||||
|
|
||||||
jobs = get_running_jobs("https://gitea.example", "owner", "repo", "token", 123)
|
|
||||||
assert len(jobs) == 2
|
|
||||||
mock_get.assert_called_once()
|
|
||||||
|
|
||||||
def test_raises_on_request_error(self) -> None:
|
|
||||||
import requests
|
|
||||||
|
|
||||||
with patch("devx.ci.integration_guard.requests.get") as mock_get:
|
|
||||||
mock_get.side_effect = requests.RequestException("boom")
|
|
||||||
with pytest.raises(requests.RequestException):
|
|
||||||
from devx.ci.integration_guard import get_running_jobs
|
|
||||||
|
|
||||||
get_running_jobs("https://gitea.example", "owner", "repo", "token", 123)
|
|
||||||
|
|
||||||
|
|
||||||
class TestAnyOtherRunnerFailed:
|
|
||||||
def test_detects_other_failure(self) -> None:
|
|
||||||
from devx.ci.integration_guard import any_other_runner_failed
|
|
||||||
|
|
||||||
jobs = [
|
|
||||||
{"name": "integration-tests (0)", "conclusion": "success"},
|
|
||||||
{"name": "integration-tests (1)", "conclusion": "failure"},
|
|
||||||
{"name": "integration-tests (2)", "conclusion": "running"},
|
|
||||||
]
|
|
||||||
assert any_other_runner_failed(jobs, "integration-tests", 0) is True
|
|
||||||
|
|
||||||
def test_ignores_current_runner(self) -> None:
|
|
||||||
from devx.ci.integration_guard import any_other_runner_failed
|
|
||||||
|
|
||||||
jobs = [
|
|
||||||
{"name": "integration-tests (0)", "conclusion": "failure"},
|
|
||||||
{"name": "integration-tests (1)", "conclusion": "success"},
|
|
||||||
]
|
|
||||||
assert any_other_runner_failed(jobs, "integration-tests", 0) is False
|
|
||||||
|
|
||||||
def test_ignores_non_matching_jobs(self) -> None:
|
|
||||||
from devx.ci.integration_guard import any_other_runner_failed
|
|
||||||
|
|
||||||
jobs = [
|
|
||||||
{"name": "quality", "conclusion": "failure"},
|
|
||||||
{"name": "integration-tests (1)", "conclusion": "success"},
|
|
||||||
]
|
|
||||||
assert any_other_runner_failed(jobs, "integration-tests", 0) is False
|
|
||||||
|
|
||||||
|
|
||||||
class TestPollForOtherFailures:
|
|
||||||
def test_sets_failed_event_when_other_runner_fails(self) -> None:
|
|
||||||
from devx.ci.integration_guard import poll_for_other_failures
|
|
||||||
|
|
||||||
stop_event = MagicMock()
|
|
||||||
failed_event = MagicMock()
|
|
||||||
|
|
||||||
def side_effect(*args, **kwargs):
|
|
||||||
if stop_event.wait.call_count < 1:
|
|
||||||
return [
|
|
||||||
{"name": "integration-tests (0)", "conclusion": "success"},
|
|
||||||
{"name": "integration-tests (1)", "conclusion": "failure"},
|
|
||||||
]
|
|
||||||
return []
|
|
||||||
|
|
||||||
with patch("devx.ci.integration_guard.get_running_jobs") as mock_get_jobs:
|
|
||||||
mock_get_jobs.side_effect = side_effect
|
|
||||||
stop_event.is_set.side_effect = [False, False]
|
|
||||||
stop_event.wait.return_value = True
|
|
||||||
|
|
||||||
poll_for_other_failures(
|
|
||||||
"https://gitea.example",
|
|
||||||
"owner",
|
|
||||||
"repo",
|
|
||||||
"token",
|
|
||||||
123,
|
|
||||||
"integration-tests",
|
|
||||||
0,
|
|
||||||
stop_event,
|
|
||||||
failed_event,
|
|
||||||
)
|
|
||||||
|
|
||||||
failed_event.set.assert_called_once()
|
|
||||||
|
|
||||||
def test_poll_warns_on_api_error(self) -> None:
|
|
||||||
import requests
|
|
||||||
|
|
||||||
from devx.ci.integration_guard import poll_for_other_failures
|
|
||||||
|
|
||||||
stop_event = MagicMock()
|
|
||||||
failed_event = MagicMock()
|
|
||||||
|
|
||||||
with patch("devx.ci.integration_guard.get_running_jobs") as mock_get_jobs:
|
|
||||||
mock_get_jobs.side_effect = requests.RequestException("boom")
|
|
||||||
stop_event.is_set.side_effect = [False, True]
|
|
||||||
stop_event.wait.return_value = True
|
|
||||||
|
|
||||||
poll_for_other_failures(
|
|
||||||
"https://gitea.example",
|
|
||||||
"owner",
|
|
||||||
"repo",
|
|
||||||
"token",
|
|
||||||
123,
|
|
||||||
"integration-tests",
|
|
||||||
0,
|
|
||||||
stop_event,
|
|
||||||
failed_event,
|
|
||||||
)
|
|
||||||
|
|
||||||
failed_event.set.assert_not_called()
|
|
||||||
|
|||||||
@@ -0,0 +1,591 @@
|
|||||||
|
"""Unit tests for scripts/ci/molecule_ci_guard.py."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess # nosec B404
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import click
|
||||||
|
import pytest
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from devx.molecule.molecule_ci_guard import (
|
||||||
|
any_other_runner_failed,
|
||||||
|
build_env_for_pair,
|
||||||
|
build_molecule_cmd,
|
||||||
|
cli,
|
||||||
|
get_running_jobs,
|
||||||
|
parse_pair,
|
||||||
|
poll_for_other_failures,
|
||||||
|
resolve_role_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetRunningJobs:
|
||||||
|
def test_returns_jobs(self) -> None:
|
||||||
|
with patch("devx.molecule.molecule_ci_guard.requests.get") as mock_get:
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"jobs": [
|
||||||
|
{"name": "molecule-tests (0)", "conclusion": "success"},
|
||||||
|
{"name": "molecule-tests (1)", "conclusion": "failure"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
mock_response.raise_for_status.return_value = None
|
||||||
|
mock_get.return_value = mock_response
|
||||||
|
|
||||||
|
jobs = get_running_jobs("https://gitea.example", "owner", "repo", "token", 123)
|
||||||
|
assert len(jobs) == 2
|
||||||
|
mock_get.assert_called_once()
|
||||||
|
|
||||||
|
def test_raises_on_request_error(self) -> None:
|
||||||
|
with patch("devx.molecule.molecule_ci_guard.requests.get") as mock_get:
|
||||||
|
mock_get.side_effect = requests.RequestException("boom")
|
||||||
|
with pytest.raises(requests.RequestException):
|
||||||
|
get_running_jobs("https://gitea.example", "owner", "repo", "token", 123)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAnyOtherRunnerFailed:
|
||||||
|
def test_detects_other_failure(self) -> None:
|
||||||
|
jobs = [
|
||||||
|
{"name": "molecule-tests (0)", "conclusion": "success"},
|
||||||
|
{"name": "molecule-tests (1)", "conclusion": "failure"},
|
||||||
|
{"name": "molecule-tests (2)", "conclusion": "running"},
|
||||||
|
]
|
||||||
|
assert any_other_runner_failed(jobs, "molecule-tests", 0) is True
|
||||||
|
|
||||||
|
def test_ignores_current_runner(self) -> None:
|
||||||
|
jobs = [
|
||||||
|
{"name": "molecule-tests (0)", "conclusion": "failure"},
|
||||||
|
{"name": "molecule-tests (1)", "conclusion": "success"},
|
||||||
|
]
|
||||||
|
assert any_other_runner_failed(jobs, "molecule-tests", 0) is False
|
||||||
|
|
||||||
|
def test_ignores_non_molecule_jobs(self) -> None:
|
||||||
|
jobs = [
|
||||||
|
{"name": "quality", "conclusion": "failure"},
|
||||||
|
{"name": "molecule-tests (1)", "conclusion": "success"},
|
||||||
|
]
|
||||||
|
assert any_other_runner_failed(jobs, "molecule-tests", 0) is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildMoleculeCmd:
|
||||||
|
def test_default_scenario(self) -> None:
|
||||||
|
assert build_molecule_cmd("default") == ["molecule", "test"]
|
||||||
|
|
||||||
|
def test_named_scenario(self) -> None:
|
||||||
|
assert build_molecule_cmd("lifecycle") == ["molecule", "test", "-s", "lifecycle"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildEnvForPair:
|
||||||
|
def test_with_command(self) -> None:
|
||||||
|
env = build_env_for_pair("default|ubuntu-2204|img:latest|/lib/systemd/systemd", {})
|
||||||
|
assert env["MOLECULE_PLATFORM_NAME"] == "ubuntu-2204"
|
||||||
|
assert env["MOLECULE_PLATFORM_IMAGE"] == "img:latest"
|
||||||
|
assert env["MOLECULE_PLATFORM_COMMAND"] == "/lib/systemd/systemd"
|
||||||
|
assert env["ANSIBLE_ALLOW_BROKEN_CONDITIONALS"] == "true"
|
||||||
|
|
||||||
|
def test_without_command(self) -> None:
|
||||||
|
env = build_env_for_pair("default|ubuntu-2204|img:latest|", {})
|
||||||
|
assert env["MOLECULE_PLATFORM_NAME"] == "ubuntu-2204"
|
||||||
|
assert "MOLECULE_PLATFORM_COMMAND" not in env
|
||||||
|
|
||||||
|
def test_without_command_removes_existing(self) -> None:
|
||||||
|
env = build_env_for_pair("default|ubuntu-2204|img:latest|", {"MOLECULE_PLATFORM_COMMAND": "old"})
|
||||||
|
assert "MOLECULE_PLATFORM_COMMAND" not in env
|
||||||
|
|
||||||
|
def test_preserves_existing_molecule_home(self) -> None:
|
||||||
|
"""When MOLECULE_HOME is already set, it is not overridden."""
|
||||||
|
env = build_env_for_pair("default|ubuntu-2204|img:latest|", {"MOLECULE_HOME": "/custom/home"})
|
||||||
|
assert env["MOLECULE_HOME"] == "/custom/home"
|
||||||
|
|
||||||
|
def test_appends_matrix_index_to_platform_name(self) -> None:
|
||||||
|
"""When MATRIX_INDEX is set, platform name gets a unique suffix."""
|
||||||
|
env = build_env_for_pair("default|ubuntu-2604|img:latest|sleep infinity", {"MATRIX_INDEX": "3"})
|
||||||
|
assert env["MOLECULE_PLATFORM_NAME"] == "ubuntu-2604-r3"
|
||||||
|
|
||||||
|
def test_no_matrix_index_keeps_platform_name(self) -> None:
|
||||||
|
"""Without MATRIX_INDEX, platform name is unchanged."""
|
||||||
|
env = build_env_for_pair("default|ubuntu-2604|img:latest|sleep infinity", {})
|
||||||
|
assert env["MOLECULE_PLATFORM_NAME"] == "ubuntu-2604"
|
||||||
|
|
||||||
|
|
||||||
|
class TestPollForOtherFailures:
|
||||||
|
def test_sets_failed_event_when_other_runner_fails(self) -> None:
|
||||||
|
stop_event = MagicMock()
|
||||||
|
failed_event = MagicMock()
|
||||||
|
|
||||||
|
def side_effect(*args, **kwargs):
|
||||||
|
if stop_event.wait.call_count < 1:
|
||||||
|
return [
|
||||||
|
{"name": "molecule-tests (0)", "conclusion": "success"},
|
||||||
|
{"name": "molecule-tests (1)", "conclusion": "failure"},
|
||||||
|
]
|
||||||
|
return []
|
||||||
|
|
||||||
|
with patch("devx.molecule.molecule_ci_guard.get_running_jobs") as mock_get_jobs:
|
||||||
|
mock_get_jobs.side_effect = side_effect
|
||||||
|
stop_event.is_set.side_effect = [False, False]
|
||||||
|
stop_event.wait.return_value = True
|
||||||
|
|
||||||
|
poll_for_other_failures(
|
||||||
|
"https://gitea.example",
|
||||||
|
"owner",
|
||||||
|
"repo",
|
||||||
|
"token",
|
||||||
|
123,
|
||||||
|
"molecule-tests",
|
||||||
|
0,
|
||||||
|
stop_event,
|
||||||
|
failed_event,
|
||||||
|
)
|
||||||
|
|
||||||
|
failed_event.set.assert_called_once()
|
||||||
|
|
||||||
|
def test_poll_warns_on_api_error(self) -> None:
|
||||||
|
stop_event = MagicMock()
|
||||||
|
failed_event = MagicMock()
|
||||||
|
|
||||||
|
with patch("devx.molecule.molecule_ci_guard.get_running_jobs") as mock_get_jobs:
|
||||||
|
mock_get_jobs.side_effect = requests.RequestException("boom")
|
||||||
|
stop_event.is_set.side_effect = [False, True]
|
||||||
|
stop_event.wait.return_value = True
|
||||||
|
|
||||||
|
poll_for_other_failures(
|
||||||
|
"https://gitea.example",
|
||||||
|
"owner",
|
||||||
|
"repo",
|
||||||
|
"token",
|
||||||
|
123,
|
||||||
|
"molecule-tests",
|
||||||
|
0,
|
||||||
|
stop_event,
|
||||||
|
failed_event,
|
||||||
|
)
|
||||||
|
|
||||||
|
failed_event.set.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
class TestCli:
|
||||||
|
def test_all_pass(self) -> None:
|
||||||
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||||
|
patch("devx.molecule.molecule_ci_guard.subprocess.run"),
|
||||||
|
patch("devx.molecule.molecule_ci_guard.subprocess.run") as mock_run,
|
||||||
|
patch("time.sleep"),
|
||||||
|
):
|
||||||
|
proc = MagicMock()
|
||||||
|
proc.poll.return_value = 0
|
||||||
|
proc.returncode = 0
|
||||||
|
mock_popen.return_value = proc
|
||||||
|
mock_run.return_value = MagicMock(returncode=0)
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "All molecule tests passed" in result.output
|
||||||
|
# Verify Docker prune was called between scenarios
|
||||||
|
mock_run.assert_called_once_with(
|
||||||
|
["docker", "system", "prune", "-af", "--volumes"],
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("devx.molecule.molecule_ci_guard.get_ci_token", side_effect=click.ClickException("no token"))
|
||||||
|
def test_missing_token_runs_without_polling(self, mock_token: MagicMock) -> None:
|
||||||
|
"""When no token is available, cross-runner polling is skipped."""
|
||||||
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||||
|
patch("devx.molecule.molecule_ci_guard.subprocess.run"),
|
||||||
|
patch("devx.molecule.molecule_ci_guard.subprocess.run") as mock_run,
|
||||||
|
patch("devx.molecule.molecule_ci_guard.poll_for_other_failures") as mock_poll,
|
||||||
|
patch("time.sleep"),
|
||||||
|
):
|
||||||
|
proc = MagicMock()
|
||||||
|
proc.poll.return_value = 0
|
||||||
|
proc.returncode = 0
|
||||||
|
mock_popen.return_value = proc
|
||||||
|
mock_run.return_value = MagicMock(returncode=0)
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
mock_poll.assert_not_called()
|
||||||
|
|
||||||
|
def test_invalid_pair_format_raises(self) -> None:
|
||||||
|
"""Pair with fewer than 2 parts should raise."""
|
||||||
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("devx.molecule.molecule_ci_guard.subprocess.Popen"),
|
||||||
|
patch("devx.molecule.molecule_ci_guard.subprocess.run"),
|
||||||
|
):
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(cli, ["invalid_no_pipe"])
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "Invalid pair format" in result.output
|
||||||
|
|
||||||
|
def test_failure_exits_nonzero(self) -> None:
|
||||||
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||||
|
patch("devx.molecule.molecule_ci_guard.subprocess.run"),
|
||||||
|
patch("devx.molecule.molecule_ci_guard.subprocess.run"),
|
||||||
|
patch("time.sleep"),
|
||||||
|
):
|
||||||
|
proc = MagicMock()
|
||||||
|
proc.poll.return_value = 1
|
||||||
|
proc.returncode = 1
|
||||||
|
mock_popen.return_value = proc
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "FAILED" in result.output
|
||||||
|
|
||||||
|
def test_exits_before_starting_when_already_failed(self) -> None:
|
||||||
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{
|
||||||
|
"GITEA_URL": "https://gitea.example",
|
||||||
|
"CI_GITEA_TOKEN": "token",
|
||||||
|
"RUN_ID": "123",
|
||||||
|
"JOB_NAME": "molecule-tests",
|
||||||
|
"MATRIX_INDEX": "0",
|
||||||
|
"GITEA_REPOSITORY": "my-org/my-repo",
|
||||||
|
"PATH": os.environ.get("PATH", ""),
|
||||||
|
},
|
||||||
|
clear=True,
|
||||||
|
),
|
||||||
|
patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01),
|
||||||
|
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||||
|
patch("devx.molecule.molecule_ci_guard.subprocess.run"),
|
||||||
|
patch("devx.molecule.molecule_ci_guard.subprocess.run"),
|
||||||
|
patch("devx.molecule.molecule_ci_guard.get_running_jobs") as mock_get_jobs,
|
||||||
|
patch("time.sleep"),
|
||||||
|
):
|
||||||
|
mock_get_jobs.return_value = [
|
||||||
|
{"name": "molecule-tests (0)", "conclusion": "running"},
|
||||||
|
{"name": "molecule-tests (1)", "conclusion": "failure"},
|
||||||
|
]
|
||||||
|
proc = MagicMock()
|
||||||
|
proc.poll.return_value = 0
|
||||||
|
proc.returncode = 0
|
||||||
|
mock_popen.return_value = proc
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "Another molecule runner failed" in result.output
|
||||||
|
|
||||||
|
def test_keyboard_interrupt_kills_process(self) -> None:
|
||||||
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||||
|
patch("devx.molecule.molecule_ci_guard.subprocess.run"),
|
||||||
|
patch("devx.molecule.molecule_ci_guard.subprocess.run"),
|
||||||
|
patch("time.sleep", side_effect=KeyboardInterrupt),
|
||||||
|
patch("os.killpg") as mock_killpg,
|
||||||
|
patch("os.getpgid") as mock_getpgid,
|
||||||
|
):
|
||||||
|
mock_getpgid.return_value = 123
|
||||||
|
proc = MagicMock()
|
||||||
|
proc.poll.return_value = None
|
||||||
|
proc.wait.return_value = 0
|
||||||
|
mock_popen.return_value = proc
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
|
||||||
|
assert result.exit_code == 1
|
||||||
|
mock_killpg.assert_called()
|
||||||
|
|
||||||
|
def test_exits_when_other_runner_fails(self) -> None:
|
||||||
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
real_sleep = time.sleep
|
||||||
|
call_count = [0]
|
||||||
|
|
||||||
|
def get_jobs_side_effect(*args, **kwargs):
|
||||||
|
call_count[0] += 1
|
||||||
|
if call_count[0] < 2:
|
||||||
|
return [{"name": "molecule-tests (1)", "conclusion": "running"}]
|
||||||
|
return [
|
||||||
|
{"name": "molecule-tests (0)", "conclusion": "running"},
|
||||||
|
{"name": "molecule-tests (1)", "conclusion": "failure"},
|
||||||
|
]
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{
|
||||||
|
"GITEA_URL": "https://gitea.example",
|
||||||
|
"CI_GITEA_TOKEN": "token",
|
||||||
|
"RUN_ID": "123",
|
||||||
|
"JOB_NAME": "molecule-tests",
|
||||||
|
"MATRIX_INDEX": "0",
|
||||||
|
"GITEA_REPOSITORY": "my-org/my-repo",
|
||||||
|
"PATH": os.environ.get("PATH", ""),
|
||||||
|
},
|
||||||
|
clear=True,
|
||||||
|
),
|
||||||
|
patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01),
|
||||||
|
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||||
|
patch("devx.molecule.molecule_ci_guard.subprocess.run"),
|
||||||
|
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
||||||
|
patch("os.killpg") as mock_killpg,
|
||||||
|
patch("os.getpgid") as mock_getpgid,
|
||||||
|
patch("time.sleep", side_effect=lambda x: real_sleep(0)),
|
||||||
|
):
|
||||||
|
mock_getpgid.return_value = 123
|
||||||
|
proc = MagicMock()
|
||||||
|
proc.poll.return_value = None
|
||||||
|
proc.wait.return_value = 0
|
||||||
|
mock_popen.return_value = proc
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
|
||||||
|
assert result.exit_code == 1
|
||||||
|
mock_killpg.assert_called()
|
||||||
|
|
||||||
|
def test_default_owner_repo_fallback(self) -> None:
|
||||||
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
real_sleep = time.sleep
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{
|
||||||
|
"GITEA_URL": "https://gitea.example",
|
||||||
|
"CI_GITEA_TOKEN": "token",
|
||||||
|
"RUN_ID": "123",
|
||||||
|
"JOB_NAME": "molecule-tests",
|
||||||
|
"MATRIX_INDEX": "0",
|
||||||
|
"GITEA_REPOSITORY": "invalid",
|
||||||
|
"PATH": os.environ.get("PATH", ""),
|
||||||
|
},
|
||||||
|
clear=True,
|
||||||
|
),
|
||||||
|
patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01),
|
||||||
|
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||||
|
patch("devx.molecule.molecule_ci_guard.subprocess.run"),
|
||||||
|
patch("devx.molecule.molecule_ci_guard.subprocess.run") as mock_run,
|
||||||
|
patch("devx.molecule.molecule_ci_guard.get_running_jobs") as mock_get_jobs,
|
||||||
|
patch("time.sleep", side_effect=lambda x: real_sleep(0)),
|
||||||
|
):
|
||||||
|
mock_get_jobs.return_value = [{"name": "molecule-tests (1)", "conclusion": "success"}]
|
||||||
|
proc = MagicMock()
|
||||||
|
proc.poll.return_value = 0
|
||||||
|
proc.returncode = 0
|
||||||
|
mock_popen.return_value = proc
|
||||||
|
mock_run.return_value = MagicMock(returncode=0)
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
def test_exits_when_other_runner_fails_with_process_lookup_error(self) -> None:
|
||||||
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
real_sleep = time.sleep
|
||||||
|
call_count = [0]
|
||||||
|
|
||||||
|
def get_jobs_side_effect(*args, **kwargs):
|
||||||
|
call_count[0] += 1
|
||||||
|
if call_count[0] < 2:
|
||||||
|
return [{"name": "molecule-tests (1)", "conclusion": "running"}]
|
||||||
|
return [
|
||||||
|
{"name": "molecule-tests (0)", "conclusion": "running"},
|
||||||
|
{"name": "molecule-tests (1)", "conclusion": "failure"},
|
||||||
|
]
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{
|
||||||
|
"GITEA_URL": "https://gitea.example",
|
||||||
|
"CI_GITEA_TOKEN": "token",
|
||||||
|
"RUN_ID": "123",
|
||||||
|
"JOB_NAME": "molecule-tests",
|
||||||
|
"MATRIX_INDEX": "0",
|
||||||
|
"GITEA_REPOSITORY": "my-org/my-repo",
|
||||||
|
"PATH": os.environ.get("PATH", ""),
|
||||||
|
},
|
||||||
|
clear=True,
|
||||||
|
),
|
||||||
|
patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01),
|
||||||
|
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||||
|
patch("devx.molecule.molecule_ci_guard.subprocess.run"),
|
||||||
|
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
||||||
|
patch("os.killpg") as mock_killpg,
|
||||||
|
patch("os.getpgid") as mock_getpgid,
|
||||||
|
patch("time.sleep", side_effect=lambda x: real_sleep(0)),
|
||||||
|
):
|
||||||
|
mock_getpgid.return_value = 123
|
||||||
|
mock_killpg.side_effect = ProcessLookupError("no such process")
|
||||||
|
proc = MagicMock()
|
||||||
|
proc.poll.return_value = None
|
||||||
|
proc.wait.return_value = 0
|
||||||
|
mock_popen.return_value = proc
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
|
||||||
|
assert result.exit_code == 1
|
||||||
|
|
||||||
|
def test_exits_when_other_runner_fails_with_timeout(self) -> None:
|
||||||
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
real_sleep = time.sleep
|
||||||
|
call_count = [0]
|
||||||
|
|
||||||
|
def get_jobs_side_effect(*args, **kwargs):
|
||||||
|
call_count[0] += 1
|
||||||
|
if call_count[0] < 2:
|
||||||
|
return [{"name": "molecule-tests (1)", "conclusion": "running"}]
|
||||||
|
return [
|
||||||
|
{"name": "molecule-tests (0)", "conclusion": "running"},
|
||||||
|
{"name": "molecule-tests (1)", "conclusion": "failure"},
|
||||||
|
]
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{
|
||||||
|
"GITEA_URL": "https://gitea.example",
|
||||||
|
"CI_GITEA_TOKEN": "token",
|
||||||
|
"RUN_ID": "123",
|
||||||
|
"JOB_NAME": "molecule-tests",
|
||||||
|
"MATRIX_INDEX": "0",
|
||||||
|
"GITEA_REPOSITORY": "my-org/my-repo",
|
||||||
|
"PATH": os.environ.get("PATH", ""),
|
||||||
|
},
|
||||||
|
clear=True,
|
||||||
|
),
|
||||||
|
patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01),
|
||||||
|
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||||
|
patch("devx.molecule.molecule_ci_guard.subprocess.run"),
|
||||||
|
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
||||||
|
patch("os.killpg") as mock_killpg,
|
||||||
|
patch("os.getpgid") as mock_getpgid,
|
||||||
|
patch("time.sleep", side_effect=lambda x: real_sleep(0)),
|
||||||
|
):
|
||||||
|
mock_getpgid.return_value = 123
|
||||||
|
mock_killpg.side_effect = [None, ProcessLookupError("no such process")]
|
||||||
|
proc = MagicMock()
|
||||||
|
proc.poll.return_value = None
|
||||||
|
proc.wait.side_effect = [subprocess.TimeoutExpired("cmd", 10)]
|
||||||
|
mock_popen.return_value = proc
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
|
||||||
|
assert result.exit_code == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_module_block() -> None:
|
||||||
|
import devx.molecule.molecule_ci_guard as mg
|
||||||
|
|
||||||
|
with open(mg.__file__) as f:
|
||||||
|
source = f.read()
|
||||||
|
source = source.replace('if __name__ == "__main__":\n cli()\n', "")
|
||||||
|
namespace = dict(mg.__dict__)
|
||||||
|
exec(compile(source, mg.__file__, "exec"), namespace)
|
||||||
|
assert callable(namespace["cli"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestParsePair:
|
||||||
|
def test_single_role_4_part(self) -> None:
|
||||||
|
role, scenario, name, image, cmd = parse_pair("default|ubuntu-2204|ubuntu:22.04|")
|
||||||
|
assert role == ""
|
||||||
|
assert scenario == "default"
|
||||||
|
assert name == "ubuntu-2204"
|
||||||
|
assert image == "ubuntu:22.04"
|
||||||
|
assert cmd == ""
|
||||||
|
|
||||||
|
def test_multi_role_5_part(self) -> None:
|
||||||
|
role, scenario, name, image, cmd = parse_pair("gitea-runner|default|ubuntu-2204|ubuntu:22.04|")
|
||||||
|
assert role == "gitea-runner"
|
||||||
|
assert scenario == "default"
|
||||||
|
assert name == "ubuntu-2204"
|
||||||
|
assert image == "ubuntu:22.04"
|
||||||
|
assert cmd == ""
|
||||||
|
|
||||||
|
def test_multi_role_with_command(self) -> None:
|
||||||
|
role, scenario, name, image, cmd = parse_pair(
|
||||||
|
"docker-base|lifecycle|archlinux|archlinux:latest|/usr/lib/systemd/systemd"
|
||||||
|
)
|
||||||
|
assert role == "docker-base"
|
||||||
|
assert scenario == "lifecycle"
|
||||||
|
assert cmd == "/usr/lib/systemd/systemd"
|
||||||
|
|
||||||
|
def test_invalid_pair_raises(self) -> None:
|
||||||
|
with pytest.raises(click.ClickException, match="Invalid pair format"):
|
||||||
|
parse_pair("only|two|parts")
|
||||||
|
|
||||||
|
def test_too_many_parts_raises(self) -> None:
|
||||||
|
with pytest.raises(click.ClickException, match="Invalid pair format"):
|
||||||
|
parse_pair("a|b|c|d|e|f")
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveRoleDir:
|
||||||
|
def test_multi_role_with_roles_root(self, tmp_path: Path) -> None:
|
||||||
|
roles_root = tmp_path / "ansible" / "roles"
|
||||||
|
roles_root.mkdir(parents=True)
|
||||||
|
result = resolve_role_dir("gitea-runner", roles_root, tmp_path)
|
||||||
|
assert result == roles_root / "gitea-runner"
|
||||||
|
|
||||||
|
def test_multi_role_default_roles_root(self, tmp_path: Path) -> None:
|
||||||
|
result = resolve_role_dir("docker-base", None, tmp_path)
|
||||||
|
assert result == tmp_path / "ansible" / "roles" / "docker-base"
|
||||||
|
|
||||||
|
def test_single_role_auto_discovers(self, tmp_path: Path) -> None:
|
||||||
|
"""Single-role mode auto-discovers first role with molecule/ dir."""
|
||||||
|
roles_dir = tmp_path / "ansible" / "roles"
|
||||||
|
(roles_dir / "my_role" / "molecule").mkdir(parents=True)
|
||||||
|
result = resolve_role_dir("", None, tmp_path)
|
||||||
|
assert result == roles_dir / "my_role"
|
||||||
|
|
||||||
|
def test_single_role_no_roles_returns_fallback(self, tmp_path: Path) -> None:
|
||||||
|
"""When no roles exist, returns a fallback path (will error at runtime)."""
|
||||||
|
result = resolve_role_dir("", None, tmp_path)
|
||||||
|
assert "roles" in str(result)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCliMultiRole:
|
||||||
|
def test_multi_role_pair_passes(self, tmp_path: Path) -> None:
|
||||||
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
roles_root = tmp_path / "ansible" / "roles"
|
||||||
|
(roles_root / "gitea-runner").mkdir(parents=True)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||||
|
patch("devx.molecule.molecule_ci_guard.subprocess.run"),
|
||||||
|
patch("devx.molecule.molecule_ci_guard.subprocess.run") as mock_run,
|
||||||
|
patch("time.sleep"),
|
||||||
|
):
|
||||||
|
proc = MagicMock()
|
||||||
|
proc.poll.return_value = 0
|
||||||
|
proc.returncode = 0
|
||||||
|
mock_popen.return_value = proc
|
||||||
|
mock_run.return_value = MagicMock(returncode=0)
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
cli,
|
||||||
|
["--roles-root", str(roles_root), "gitea-runner|default|ubuntu-2204|ubuntu:22.04|"],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "All molecule tests passed" in result.output
|
||||||
Reference in New Issue
Block a user