Build images / build-ml (push) Successful in 4s
CI / lint (push) Successful in 4s
Build images / build-agent (push) Successful in 5s
CI / extension-version (push) Successful in 4s
CI / frontend-build (push) Successful in 22s
extension / lint (push) Successful in 22s
CI / backend-lint-and-test (push) Failing after 33s
Build images / sign-extension (push) Successful in 2m24s
Build images / build-web (push) Successful in 2m38s
CI / integration (push) Successful in 5m15s
`1.0.<minutes since 2020>` -> `YYYY.M.D.HHMM` UTC, from the commit time of
the newest change to a packaged extension file. Same clock and same commit as
before; readable instead of opaque, and the same value the rest of the family
derives.
The hold on this step was two questions about AMO, and Mozilla's own docs
answer both:
^(0|[1-9][0-9]{0,8})([.](0|[1-9][0-9]{0,8})){0,3}$
1. four all-numeric segments -> ACCEPTED ({0,3} more after the first).
2. leading zeros -> REJECTED. A segment is the single digit
`0` or starts 1-9, so `08` and `0201` are refused. MDN says it in prose
too: "Non-zero numbers must not include a leading zero."
So the documented fallback applies, extension only: the same numbers rendered
without the family's zero-padding. `2026.08.29.0201` and `2026.8.29.201` are
one value in two renderings — rule 148 defines comparison as numeric per
segment, under which they are equal — so nothing already published is
reordered, and left-padding each segment recovers the family string exactly.
HHMM stays one segment because AMO allows at most four.
The transition is safe in the other direction too: 2026 > 1, so every CalVer
outranks every published 1.0.x. build.yml's downgrade guard confirms it.
Also in scope:
* MAJOR.MINOR is gone. `cmd_major_minor`, `cmd_patch` and VERSION_EPOCH go
with it, the committed version in manifest.json / package.json is now
wholly inert, and ci.yml's MAJOR.MINOR-agreement check is retired rather
than left running beside a fact that stopped existing (rule 22).
* ci.yml's `extension-version` lane now asserts Mozilla's regex verbatim
instead of a loose `^[0-9]+(\.[0-9]+)*$` — which would have passed the
padded shape. It also asserts YYYY.M.D.HHMM, because AMO would accept a
regression to `1.0.<minutes>` while that orders below everything signed
since. Checking here is the point: AMO 409s on re-signing, so a version it
rejects is burned and cannot be reused.
* `artifacts.sh version extension` delegates to packaging.sh, so the two
cannot answer differently. The direction matches the existing one —
artifacts.sh already asks packaging.sh for the extension's path set.
#3156 is what makes this commit safe to make: packaging.sh is in web's path
set, so the web revision moves with the extension version and build-web
rebuilds instead of republishing an image bundling the previous XPI.
Scribe #3138.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
192 lines
9.4 KiB
JavaScript
192 lines
9.4 KiB
JavaScript
import { describe, it, expect } from 'vitest'
|
|
import { readdirSync, readFileSync } from 'node:fs'
|
|
import { execFileSync } from 'node:child_process'
|
|
import { fileURLToPath } from 'node:url'
|
|
import path from 'node:path'
|
|
|
|
const EXT_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
const read = (name) => JSON.parse(readFileSync(path.join(EXT_DIR, name), 'utf8'))
|
|
const readText = (...seg) => readFileSync(path.join(EXT_DIR, ...seg), 'utf8')
|
|
|
|
// Only the git-free subcommands are exercised here: `version` shells out to
|
|
// git, and the extension lane runs on node:24-bookworm-slim which may not ship
|
|
// it. That one is covered where git is guaranteed — ci.yml's extension-version
|
|
// lane and build.yml both run on ci-python.
|
|
const packaging = (cmd) =>
|
|
execFileSync('sh', [path.join(EXT_DIR, 'scripts', 'packaging.sh'), cmd], {
|
|
cwd: EXT_DIR,
|
|
encoding: 'utf8'
|
|
})
|
|
.trim()
|
|
.split(/\s+/)
|
|
.filter(Boolean)
|
|
|
|
describe('packaging.sh — the single definition of what ships', () => {
|
|
it('emits an ignore list and a pathspec that agree on the tracked files', () => {
|
|
const ignore = packaging('ignore')
|
|
const pathspec = packaging('pathspec').map((e) => e.replace(':(exclude)extension/', ''))
|
|
|
|
// Every git-excluded path must also be hidden from web-ext. The reverse is
|
|
// not required: node_modules and web-ext-artifacts are build output git
|
|
// never tracks, so they appear only in the ignore list.
|
|
for (const entry of pathspec) {
|
|
expect(ignore, `pathspec has "${entry}" but --ignore-files does not`).toContain(entry)
|
|
}
|
|
expect(pathspec.length).toBeGreaterThan(0)
|
|
expect(ignore).toContain('node_modules')
|
|
})
|
|
|
|
it('emits glob patterns literally, never expanded against the working tree', () => {
|
|
// The script iterates its lists with deliberate word-splitting, so it must
|
|
// run with pathname expansion off. Without that, invoking it from a cwd
|
|
// where test/ exists (exactly how ci.yml and vitest call it) expands
|
|
// `test/**` into the individual spec files, and the pathspec silently stops
|
|
// covering anything added later.
|
|
const pathspec = packaging('pathspec')
|
|
expect(pathspec).toContain(':(exclude)extension/test/**')
|
|
expect(pathspec.some((e) => e.includes('.spec.js'))).toBe(false)
|
|
expect(pathspec.some((e) => e.includes('helpers'))).toBe(false)
|
|
|
|
const ignore = packaging('ignore')
|
|
expect(ignore).toContain('test/**')
|
|
expect(ignore).toContain('scripts/**')
|
|
expect(ignore.some((e) => e.includes('.spec.js'))).toBe(false)
|
|
})
|
|
|
|
it('lets packaging.sh move the version, though it never ships in the XPI', () => {
|
|
// The two lists answer different questions and this is the one place they
|
|
// disagree. scripts/ is ignored by web-ext — it is repo tooling, not addon
|
|
// code — but packaging.sh DECIDES the version string, and build.yml stamps
|
|
// that string into the manifest.json that does ship. So changing how the
|
|
// version is computed changes the shipped bytes.
|
|
//
|
|
// Excluding it from the pathspec was invisible while every push rebuilt the
|
|
// web image. Milestone 313 step 4 made that rebuild conditional on the
|
|
// derived revision moving, and the omission turned into a silent failure:
|
|
// a new version means sign-extension misses its ext-<version> cache and
|
|
// signs, while build-web sees an unmoved revision, reuses the published
|
|
// image and ships the OLD XPI. An orphaned signature, and an instance
|
|
// serving code the registry calls current.
|
|
const pathspec = packaging('pathspec')
|
|
expect(
|
|
pathspec.some((e) => e.startsWith(':(exclude)extension/scripts')),
|
|
'the pathspec excludes scripts/, so a change to how the version is '
|
|
+ 'derived would not move the version it derives',
|
|
).toBe(false)
|
|
|
|
// ...and it is still kept out of the package itself. Both must hold: the
|
|
// tempting "fix" for either half is to make the two lists one again.
|
|
expect(packaging('ignore')).toContain('scripts')
|
|
})
|
|
|
|
it('keeps its own scripts and specs out of the XPI', () => {
|
|
// Both are repo infrastructure. web-ext packages everything not ignored, so
|
|
// omitting either would ship dev tooling to users -- and `test/**` in
|
|
// particular only survives because callers `set -f` before substituting it.
|
|
const ignore = packaging('ignore')
|
|
expect(ignore).toContain('vitest.config.js')
|
|
// Both forms per directory. The glob covers the contents; the bare name
|
|
// covers the directory ENTRY, which web-ext writes separately — with only
|
|
// the glob, the XPI carries an empty `test/` and `scripts/`.
|
|
for (const dir of ['test', 'scripts']) {
|
|
expect(ignore, `${dir} contents`).toContain(`${dir}/**`)
|
|
expect(ignore, `${dir} directory entry`).toContain(dir)
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('consumers delegate rather than keeping their own copy', () => {
|
|
// These assertions are the actual anti-regression value: it is easy for a
|
|
// future edit to "simplify" by inlining a literal list again, which silently
|
|
// reintroduces the drift that issue #2397 was about.
|
|
it('package.json derives --ignore-files from the script', () => {
|
|
for (const [name, script] of Object.entries(read('package.json').scripts)) {
|
|
if (!script.includes('--ignore-files')) continue
|
|
expect(script, `${name} should call packaging.sh`).toContain('scripts/packaging.sh ignore')
|
|
expect(script, `${name} must set -f before the substitution`).toMatch(/set -f;/)
|
|
}
|
|
})
|
|
|
|
// Read from disk rather than listed by hand. The point of this assertion is
|
|
// that it survives consumers coming and going, and a hardcoded list is the
|
|
// one part of it that cannot — release.yml (milestone 318 step 7) would have
|
|
// joined the directory without joining the check.
|
|
const WORKFLOWS = readdirSync(path.join(EXT_DIR, '..', '.forgejo', 'workflows')).filter((f) =>
|
|
f.endsWith('.yml')
|
|
)
|
|
|
|
it('no workflow hardcodes the packaged-file set', () => {
|
|
// ci.yml used to substitute `packaging.sh pathspec` directly, for the
|
|
// manual-bump guard that milestone 271 step 5 retired. Nothing inlines the
|
|
// set today, and nothing should start to: a literal :(exclude)extension/...
|
|
// in a workflow means someone bypassed the shared definition, which is
|
|
// exactly the drift #2397 was about.
|
|
expect(
|
|
WORKFLOWS.length,
|
|
'no workflows found — the glob is not looking where it thinks'
|
|
).toBeGreaterThan(2)
|
|
for (const wf of WORKFLOWS) {
|
|
const text = readText('..', '.forgejo', 'workflows', wf)
|
|
expect(text, `${wf} inlines an :(exclude) literal`).not.toMatch(/:\(exclude\)extension\//)
|
|
}
|
|
})
|
|
|
|
it('build.yml takes the shipped version from the script, not from the repo', () => {
|
|
// The version is DERIVED from commit time (#3092, milestone 271 step 4).
|
|
// Going back to reading the committed value is not a style regression, it
|
|
// is the bug: a hand-set version makes dev and main sign the same number
|
|
// for different code, and the ext-<version> cache then serves one channel
|
|
// the other's XPI.
|
|
const build = readText('..', '.forgejo', 'workflows', 'build.yml')
|
|
expect(build).toContain('packaging.sh version')
|
|
expect(build, 'build.yml re-reads the committed version instead of deriving it')
|
|
.not.toMatch(/grep[^\n]*'"version"'[^\n]*package\.json/)
|
|
})
|
|
})
|
|
|
|
describe('extension version', () => {
|
|
// Mozilla's published grammar for addons.mozilla.org, transcribed from MDN's
|
|
// manifest.json/version page. Each segment is the single digit 0 or starts
|
|
// 1-9 — so no leading zeros — and there are at most four of them.
|
|
const AMO = /^(0|[1-9][0-9]{0,8})(\.(0|[1-9][0-9]{0,8})){0,3}$/
|
|
|
|
it('keeps a committed version AMO would accept, though it ships nothing', () => {
|
|
// The committed value is wholly inert since milestone 318 step 8: there is
|
|
// no hand-set MAJOR.MINOR left for packaging.sh to read, and build.yml
|
|
// stamps the derived string over both files before web-ext sees them.
|
|
//
|
|
// It is still asserted, for one reason: `npm run build` locally packages
|
|
// whatever is committed, so a value AMO would reject turns a local build
|
|
// into a confusing failure with no CI signal ahead of it. ci.yml checks
|
|
// the same grammar against the DERIVED value, which is the one AMO sees.
|
|
for (const file of ['manifest.json', 'package.json']) {
|
|
expect(read(file).version, `${file} version is not AMO-shaped`).toMatch(AMO)
|
|
}
|
|
})
|
|
|
|
it('rejects the zero-padded family shape, which is why the extension unpads', () => {
|
|
// Guards the reason for the exception, not just its result. If this ever
|
|
// starts passing, someone has loosened the pattern and the next sign burns
|
|
// an AMO version to find out. (#3138.)
|
|
expect('2026.08.29.0201').not.toMatch(AMO)
|
|
expect('2026.8.29.201').toMatch(AMO)
|
|
// Five segments: AMO allows four.
|
|
expect('2026.8.29.2.1').not.toMatch(AMO)
|
|
})
|
|
|
|
it('declares manifest v3', () => {
|
|
expect(read('manifest.json').manifest_version).toBe(3)
|
|
})
|
|
|
|
it('lists every background script that exists, in dependency order', () => {
|
|
// url.js must load BEFORE api.js: api.js calls normalizeApiUrl at
|
|
// init()-time, and these are classic scripts sharing one scope, so a
|
|
// reordering here is a runtime ReferenceError with no build-time signal.
|
|
const scripts = read('manifest.json').background.scripts
|
|
for (const rel of scripts) {
|
|
expect(() => readFileSync(path.join(EXT_DIR, rel)), `missing ${rel}`).not.toThrow()
|
|
}
|
|
expect(scripts.indexOf('lib/url.js')).toBeLessThan(scripts.indexOf('lib/api.js'))
|
|
})
|
|
})
|