You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Releasing meant two manual uploads: pull the zips off the build job, then hand
them to the Chrome Web Store dashboard and the AMO developer hub. This adds publish.yaml, which does both.
When it runs. On release: released — the moment someone promotes the
pre-release that build.yaml already opens for the tag. That promotion is the
human decision; nothing fires off a bare tag push, because a store submission
cannot be withdrawn. Both jobs sit in a store-release environment, so adding
required reviewers there gives a second gate. There is also a workflow_dispatch entry point taking a tag, a store, and a dry_run flag,
for when one store fails and the other succeeded.
What it submits. The release's own artifacts, not a rebuild. Each job checks
the artifact's build provenance and that the version inside manifest.json
matches the tag before anything leaves the runner. Both stores then review by
hand over hours to days, so neither job waits — for Firefox, --approval-timeout 0 returns as soon as AMO accepts the upload.
Chrome authentication stores nothing. The obvious route is an OAuth refresh
token in a secret, and that is what most published recipes do. It has two
problems: it is a standing grant derived from one person clicking through a
consent screen, and Google expires it after six months unused — longer than the
gap between some of our releases, so the first symptom would be a failed
release. API v2 accepts service accounts, so the job exchanges GitHub's OIDC
token for an access token good for an hour. What is stored is a provider
resource name and a service account email; neither grants anything alone, and
the authority behind them is an IAM binding revocable on Google's side.
chrome-webstore-upload-cli can't take an externally minted token — it always
derives one from a client ID and refresh token — so bin/publish_chrome.sh
calls the three v2 endpoints itself. It is a short script, and it matches the
upload headers, uploadState values and fetchStatus polling of the
maintained chrome-webstore-upload v6 client.
Firefox is the other way round: AMO has no federated equivalent, so the issuer
and secret are stored. They don't expire.
Source code for AMO. Mozilla requires sources whenever the shipped code is
bundled or minified, which ours is — popup.bundle.js comes out of webpack in
production mode. The Firefox job attaches an archive of the tagged commit
(bin/package_source.sh) and SOURCE_BUILD_INSTRUCTIONS.md tells a reviewer
how to get from it back to dist/, with the pinned Bun version and both the
Docker and bare paths. Without that the submission is liable to be rejected.
Version pinning note. Chrome Web Store API v1.1 is switched off after 15
October 2026 and most publishing actions on the Marketplace still speak it, so
anything that replaces bin/publish_chrome.sh needs to be checked for v2.
Bug fixed along the way
build.yaml's attestation step was conditioned on github.event.pull_request.head.repo.full_name == github.repository. On a tag
push there is no pull_request, so the condition is false and the step is
skipped — release artifacts have never carried provenance, and
returns a 404 today, despite CONTRIBUTING.md telling people to run it. The
intent was clearly to skip forks, which cannot mint attestations, so the
condition now also covers tags. The new verification step depends on this; it
will fail for tags released before this merges, which is the correct answer for
artifacts that genuinely have no provenance.
Two things for a human to decide
Neither is fixed here.
web-ext lint warns that strict_min_version: 102 predates Firefox 109,
which is where host_permissions landed. On 102–108 the extension installs
with its host permissions ignored. Raising the floor is a compatibility call.
It also warns that browser_specific_settings.gecko.data_collection_permissions
is missing. Mozilla requires it for new extensions and has said it will extend
that to new versions of existing ones. Declaring what the extension collects is
a statement about the product, not a CI detail.
Before the next release
The Google side — service account, its workload identity binding to this
repository, the Chrome Web Store API — and the store-release environment with
its reviewers and allowed refs are Terraform, in the infrastructure repo. Two
things it can't do: the Chrome Web Store has no API for registering a service
account, so someone with publisher access pastes the email into Account in
the dashboard; and CHROME_PUBLISHER_ID, AMO_JWT_ISSUER and AMO_JWT_SECRET
are set on the environment by hand rather than written into Terraform state.
Then run it once with dry_run: Chrome leaves an unsubmitted draft, Firefox
only lints.
Checklist
I have performed a self-review of my own code.
New and existing unit tests pass locally and on CI with my changes.
I have tested my changes on Google Chrome.
I have tested my changes on Mozilla Firefox.
I added a documentation for the changes I have made (when necessary).
Additional Information
No extension code changes, so the browser checkboxes are untestable here. What I
did verify locally:
actionlint (with shellcheck) clean on publish.yaml; shellcheck clean on
both new scripts. The two pre-existing SC2046 warnings in build.yaml and test.yaml are untouched.
bin/publish_chrome.sh against a stubbed API, eight cases: success; IN_PROGRESS then SUCCEEDED via fetchStatus; FAILED; an HTTP error with
the store's message surfaced; SUBMIT=false stopping before the publish call;
the poll timing out; a missing package; and a missing variable. Each exits with
the right status and makes exactly the expected calls.
Built dist/ with bunx webpack --config webpack.prod.js, zipped it the way build.yaml does, and ran the workflow's version check against it — manifest.json is at the zip root, both the unzip -p and the unpacked form
resolve correctly.
web-ext lint --source-dir dist: 0 errors, 13 warnings, exit 0, so the lint
gate passes on a real build.
gh release download --pattern ... --output against v1.6.1, and the
attestation 404 above.
The credential guard and tag parsing, against present, missing and malformed
inputs.
bun run test: 37 specs, 0 failures.
What cannot be tested from here is the live contract with each store: the
request shapes are matched against the reference clients, but the first real run
is the test.
When dry_run is true, Firefox is documented to only lint, but this credential check still runs and fails if the AMO secrets are absent before linting starts. Gate the check on !inputs.dry_run so the dry run can perform its intended credential-free validation; release events still evaluate this as true.
Select the manifest template based on PLATFORM
SOURCE_BUILD_INSTRUCTIONS.md:46
The bare-metal example always reads manifest-firefox.json, so setting PLATFORM=chrome—which this document says is supported—still builds the Firefox manifest. Select the template from PLATFORM here so the documented Chrome reproduction is correct.
Pin web-ext to the workflow's installed version
SOURCE_BUILD_INSTRUCTIONS.md:62
web-ext is not a project dependency, so this unversioned bunx command performs an additional registry fetch and can use a different tool version from the workflow's pinned web-ext@10.7.0; it also conflicts with the claim that no network is needed beyond dependency installation. Pin and install the same version for these instructions, or revise the network/reproducibility documentation.
Publishing was two manual uploads: download the zips from the build job,
then hand them to the Chrome Web Store dashboard and the AMO developer hub.
publish.yaml now does it. It runs when a release is promoted out of
pre-release, downloads that release's own artifacts rather than rebuilding
them, verifies their provenance and their version against the tag, and
submits: Chrome via chrome-webstore-upload-cli, Firefox via web-ext sign
against the listed channel. Both stores review by hand afterwards, so
neither job waits for the result.
AMO requires source alongside a bundled build, so the Firefox job attaches
an archive of the tagged commit and SOURCE_BUILD_INSTRUCTIONS.md tells a
reviewer how to reproduce dist/ from it.
Attestation was skipped on tag builds, because its condition only held for
pull requests from this repository. Release artifacts therefore carried no
provenance at all, contrary to what CONTRIBUTING.md claimed, and the new
verification step depends on it. The condition now covers tags.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
npx resolved chrome-webstore-upload-cli@4 and web-ext@10 on every run, so a
bad release inside either major would have run with the store credentials in
its environment. Both are now exact versions, installed in a separate step
that holds no secrets and into $RUNNER_TEMP rather than the checkout, so the
credentialed steps only execute code that is already on disk.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
A refresh token is minted by a human consenting in a browser, so CI can only
use one by keeping a copy. That copy is a standing grant derived from one
person's consent, and Google expires it after six months unused — longer than
some gaps between our releases, so the likeliest first symptom is a failed
release.
API v2 accepts service accounts, so the workflow now exchanges GitHub's OIDC
token for a Google access token valid for an hour. What the repository stores
is a provider resource name and a service account email, neither of which
grants anything by itself; the authority is an IAM binding on Google's side,
revocable without touching this repository.
chrome-webstore-upload-cli cannot take an externally minted token — it always
derives one from a client ID and refresh token — so bin/publish_chrome.sh calls
the three v2 endpoints directly, matching the upload headers, the uploadState
values and the fetchStatus polling of the reference client.
Firefox is unchanged: AMO has no federated equivalent, and its issuer and
secret do not expire.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
The reason will be displayed to describe this comment to others. Learn more.
Copilot review overview
🟡 Changes recommended
Chrome retries can use the wrong script revision, asynchronous upload failures hide their diagnostics, and the documented Google setup omits the Security Token Service API.
Get a fresh assessment by requesting another Copilot review.
The Firefox job checked out the released tag, so a manual retry paired the
current workflow with whatever bin/package_source.sh looked like at that tag —
and for any tag older than this branch, with no script at all. It now checks
out the default ref like the Chrome job and passes the tag to the script,
which already takes REF, so the archive is still cut from the released commit
while the script itself matches the workflow driving it.
Also enable the Security Token Service API in the setup steps: the OIDC
exchange runs on it, so omitting it fails at authentication rather than at
setup.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
This fallback cannot work as documented: the Chrome preflight in .github/workflows/publish.yaml still requires GCP_WORKLOAD_IDENTITY_PROVIDER, while this mode supplies GCP_SERVICE_ACCOUNT_KEY instead, so the job exits before google-github-actions/auth runs. Update the credential guard and the credential table along with this instruction so JSON-key mode requires the key and omits the WIF provider.
Undocumented network dependency for web-ext and Docker image
SOURCE_BUILD_INSTRUCTIONS.md:21
web-ext is not listed in package.json, so bunx web-ext lint below downloads another package from the registry (and the Docker path may also pull oven/bun:1.4.2). That makes the statement that no network is needed beyond the dependency install inaccurate; either document/pin this additional install or revise the network prerequisite.
The reason will be displayed to describe this comment to others. Learn more.
Copilot review overview
🔵 Needs a closer look
The source-build instructions currently produce the Firefox manifest for Chrome and contain an inaccurate network prerequisite.
Review effort: Lite Findings: None
Previously missed (2)
In code that hasn't changed since last review
Declare or document the web-ext dependency for offline verification
SOURCE_BUILD_INSTRUCTIONS.md:21
web-ext is not present in package.json or bun.lock, so bunx web-ext below will fetch another package during verification. That contradicts the claim that no network access is needed beyond dependency installation and prevents an offline reviewer from running all listed checks; either install/pin web-ext as part of setup or qualify this prerequisite.
Use the platform-specific manifest template for non-Docker builds
SOURCE_BUILD_INSTRUCTIONS.md:46
The non-Docker instructions always generate manifest.json from manifest-firefox.json, even though this section says the same steps support both platforms. Following these steps for the Chrome release adds Firefox's browser_specific_settings.gecko block, so the rebuilt package cannot match the Chrome artifact; select the template from the requested platform here.
The workload identity provider resource name and the service account email
are not secrets — neither authorises anything without the IAM binding behind
them — and keeping them secret hid which identity a failed run had tried. They
are now environment variables, provisioned alongside the environment itself,
so the value in GitHub follows the value in the Google project instead of
being copied across by hand.
CONTRIBUTING.md records which values are provisioned and which are set by
hand, and keeps the one step the Chrome Web Store has no API for.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
The reason will be displayed to describe this comment to others. Learn more.
Copilot review overview
🟡 Changes recommended
The documented no-Docker rebuild selects the Firefox manifest for every platform, and asynchronous Chrome upload failures discard the store’s detailed status response.
Get a fresh assessment by requesting another Copilot review.
jq --arg version "<version>" '.version = $version' \
src/manifest-firefox.json > src/manifest.json
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Releasing meant two manual uploads: pull the zips off the build job, then hand
them to the Chrome Web Store dashboard and the AMO developer hub. This adds
publish.yaml, which does both.When it runs. On
release: released— the moment someone promotes thepre-release that
build.yamlalready opens for the tag. That promotion is thehuman decision; nothing fires off a bare tag push, because a store submission
cannot be withdrawn. Both jobs sit in a
store-releaseenvironment, so addingrequired reviewers there gives a second gate. There is also a
workflow_dispatchentry point taking a tag, a store, and adry_runflag,for when one store fails and the other succeeded.
What it submits. The release's own artifacts, not a rebuild. Each job checks
the artifact's build provenance and that the version inside
manifest.jsonmatches the tag before anything leaves the runner. Both stores then review by
hand over hours to days, so neither job waits — for Firefox,
--approval-timeout 0returns as soon as AMO accepts the upload.Chrome authentication stores nothing. The obvious route is an OAuth refresh
token in a secret, and that is what most published recipes do. It has two
problems: it is a standing grant derived from one person clicking through a
consent screen, and Google expires it after six months unused — longer than the
gap between some of our releases, so the first symptom would be a failed
release. API v2 accepts service accounts, so the job exchanges GitHub's OIDC
token for an access token good for an hour. What is stored is a provider
resource name and a service account email; neither grants anything alone, and
the authority behind them is an IAM binding revocable on Google's side.
chrome-webstore-upload-clican't take an externally minted token — it alwaysderives one from a client ID and refresh token — so
bin/publish_chrome.shcalls the three v2 endpoints itself. It is a short script, and it matches the
upload headers,
uploadStatevalues andfetchStatuspolling of themaintained
chrome-webstore-uploadv6 client.Firefox is the other way round: AMO has no federated equivalent, so the issuer
and secret are stored. They don't expire.
Source code for AMO. Mozilla requires sources whenever the shipped code is
bundled or minified, which ours is —
popup.bundle.jscomes out of webpack inproduction mode. The Firefox job attaches an archive of the tagged commit
(
bin/package_source.sh) andSOURCE_BUILD_INSTRUCTIONS.mdtells a reviewerhow to get from it back to
dist/, with the pinned Bun version and both theDocker and bare paths. Without that the submission is liable to be rejected.
Version pinning note. Chrome Web Store API v1.1 is switched off after 15
October 2026 and most publishing actions on the Marketplace still speak it, so
anything that replaces
bin/publish_chrome.shneeds to be checked for v2.Bug fixed along the way
build.yaml's attestation step was conditioned ongithub.event.pull_request.head.repo.full_name == github.repository. On a tagpush there is no
pull_request, so the condition is false and the step isskipped — release artifacts have never carried provenance, and
returns a 404 today, despite
CONTRIBUTING.mdtelling people to run it. Theintent was clearly to skip forks, which cannot mint attestations, so the
condition now also covers tags. The new verification step depends on this; it
will fail for tags released before this merges, which is the correct answer for
artifacts that genuinely have no provenance.
Two things for a human to decide
Neither is fixed here.
web-ext lintwarns thatstrict_min_version: 102predates Firefox 109,which is where
host_permissionslanded. On 102–108 the extension installswith its host permissions ignored. Raising the floor is a compatibility call.
browser_specific_settings.gecko.data_collection_permissionsis missing. Mozilla requires it for new extensions and has said it will extend
that to new versions of existing ones. Declaring what the extension collects is
a statement about the product, not a CI detail.
Before the next release
The Google side — service account, its workload identity binding to this
repository, the Chrome Web Store API — and the
store-releaseenvironment withits reviewers and allowed refs are Terraform, in the infrastructure repo. Two
things it can't do: the Chrome Web Store has no API for registering a service
account, so someone with publisher access pastes the email into Account in
the dashboard; and
CHROME_PUBLISHER_ID,AMO_JWT_ISSUERandAMO_JWT_SECRETare set on the environment by hand rather than written into Terraform state.
Then run it once with
dry_run: Chrome leaves an unsubmitted draft, Firefoxonly lints.
Checklist
Additional Information
No extension code changes, so the browser checkboxes are untestable here. What I
did verify locally:
actionlint(with shellcheck) clean onpublish.yaml;shellcheckclean onboth new scripts. The two pre-existing
SC2046warnings inbuild.yamlandtest.yamlare untouched.bin/publish_chrome.shagainst a stubbed API, eight cases: success;IN_PROGRESSthenSUCCEEDEDviafetchStatus;FAILED; an HTTP error withthe store's message surfaced;
SUBMIT=falsestopping before the publish call;the poll timing out; a missing package; and a missing variable. Each exits with
the right status and makes exactly the expected calls.
dist/withbunx webpack --config webpack.prod.js, zipped it the waybuild.yamldoes, and ran the workflow's version check against it —manifest.jsonis at the zip root, both theunzip -pand the unpacked formresolve correctly.
web-ext lint --source-dir dist: 0 errors, 13 warnings, exit 0, so the lintgate passes on a real build.
gh release download --pattern ... --outputagainstv1.6.1, and theattestation 404 above.
inputs.
bun run test: 37 specs, 0 failures.What cannot be tested from here is the live contract with each store: the
request shapes are matched against the reference clients, but the first real run
is the test.
🤖 Generated with Claude Code