Skip to content

ci: add release.yml so the npm trusted-publisher binding becomes live - #81

Merged
andrei-hasna merged 2 commits into
mainfrom
relworkflow-9ff3471a
Aug 3, 2026
Merged

ci: add release.yml so the npm trusted-publisher binding becomes live#81
andrei-hasna merged 2 commits into
mainfrom
relworkflow-9ff3471a

Conversation

@andrei-hasna

@andrei-hasna andrei-hasna commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

What this closes

npm trusted publishing is already configured for @hasna/conversations. The
binding names --file release.yml --env npm-release --allow-publish.

That file did not exist in this repository. Measured 2026-08-03:
.github/workflows/release.yml returned 404, verified with a positive control
in the same call so the probe is known to work. The binding was therefore
inert — it named a workflow that never ran and so never matched — and every
release has continued to be hand-published from a workstation.

The filename release.yml and the environment npm-release are not free
choices.
Both strings are already recorded on npm's side as part of the live
trusted-publisher configuration. Renaming either one silently de-authorises
publishing: the binding stops matching and the failure surfaces as an auth
error that never mentions the rename. Change them only together with
npm trust.

Why a minimal OIDC workflow rather than a port of the accounts harness

hasna/accounts/.github/workflows/release.yml is the only release pipeline on
this fleet that has demonstrably reached the publish step, and it was the
reference. It was deliberately not copied. Three reasons, the first two
being the strongest:

  1. A verbatim port hard-fails at its first step. It requires
    NPM_DIST_TAG_TOKEN, RELEASE_APP_ID and RELEASE_APP_PRIVATE_KEY to
    exist in the repository's npm-release environment, and gates on their
    presence before doing anything else. None of those secrets exists in this
    repository.
    Porting it produces a workflow that is red on arrival: the npm
    binding stays inert and CI goes red.
  2. It would reintroduce exactly what this migration removes. Its promote
    step authenticates with NPM_DIST_TAG_TOKEN — a granular npm token, which
    is precisely the credential class npm removes from direct publishing around
    January 2027. The whole point of moving to CI is to stop depending on that.
  3. Provenance does not require it. npm generates provenance attestations
    automatically under trusted publishing, so the harness is not what buys
    provenance here. It also depends on scripts/release-provenance.ts, which
    does not exist in this repository.

The condition that makes point 3 true, stated because the next person will
not know it:
npm does not generate provenance for private
repositories
, even when the package itself is public. hasna/conversations is
public (private=false, measured, with a control read against a known-private
repo). If this repository is ever made private, provenance silently stops
being produced and nothing in this workflow will tell you.

What this gives up, stated rather than glossed: no App-minted admin token,
no live ruleset preflight, no staged-then-promoted publish, and no
re-verification of controls between stages. Those are real properties of the
accounts pipeline that this workflow does not have. They are a follow-up gated
on someone provisioning those three environment secrets — a release-policy
decision, not something to smuggle into a CI plumbing PR.

What it does

  • Triggers on npm/conversations/v* tags. This convention already exists in this repository
    and matches what hasna/accounts uses.
  • permissions: id-token: write on the job. The workflow contains no npm
    token of any kind
    — that is the entire point. npm exchanges the Actions
    OIDC id-token for a short-lived, publish-scoped credential.
  • Publishes with npm publish, not bun publish: bun has no OIDC trusted
    publishing support and cannot authenticate here at all.
  • --provenance is passed explicitly. npm documents provenance as automatic
    under trusted publishing; that has been reported not to hold in practice, and
    the flag is a no-op when it already is, so passing it strictly dominates.
  • Two gates fail closed before the suite runs: the tag must agree with
    package.json, and the version must not already exist on the registry, since
    npm versions are immutable.
  • The tag shape is validated rather than blindly stripped — an unrecognised
    prefix is rejected by name.
  • An npm-version assertion fails with a named remedy if the bundled npm is
    below 11.5.1, the minimum for trusted publishing.
  • workflow_dispatch with dry_run defaulting to true runs every gate and
    the OIDC mint without publishing. This workflow has never executed, so
    there is a way to prove the wiring before a real release depends on it.

The npm-release environment is deliberately unprotected

It was created with no protection rules, and that is a considered choice rather
than an omission.

The npm trust binding is itself the gate. It binds the triple
(repository, workflow file, environment), so npm rejects any publish whose
OIDC claim names a different workflow file. An unprotected environment
therefore does not let some other workflow in this repository publish the
package.

Meanwhile a misconfigured deployment-branch policy silently blocks the
tag-triggered release — which is the exact failure mode this PR exists to
remove. Tag-restriction policies are worth evaluating as defence-in-depth, and
that is being handled separately rather than guessed at here.

Verification performed

  • YAML parses; environment: npm-release and id-token: write confirmed by
    reading the parsed tree, not by eyeballing the file.
  • The npm-version gate was controlled in both directions: 11.5.0 rejected,
    11.5.1 accepted — it can both pass and fail, at the correct boundary.
  • The tag-binding gate was controlled in both directions: matching tag
    accepted, mismatched version rejected, unrecognised prefix rejected.
  • The npm-release GitHub environment was created and read back from the
    forge by an independent call, with a 404 negative control proving the read
    discriminates.
  • Staged secrets scan clean, with a positive control proving the pattern is not
    vacuous. The workflow contains no credential by construction.
  • Actions are pinned to the same commit SHAs hasna/accounts already uses, so
    no new unvetted action version enters the fleet.

Gate results on this branch

  • bun run typecheck — exit 0.
  • bun test was not run on this branch. The station was at loadavg 29 on 20 cores and running eight full suites was disproportionate for a change that adds one YAML file under .github/workflows/ which no test imports. The equivalent comparison was done once, on hasna/todos: its integration failures reproduce identically on unmodified main at the same base commit, confirming they are pre-existing. CI on this PR exercises the repository's own suite.

Task: A3-00308


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

The trusted publisher for this package is already configured on npm and names
`--file release.yml --env npm-release --allow-publish`. Measured 2026-08-03,
that file returned 404 in this repository, so the binding named a workflow that
never ran and therefore never matched. Publishing has stayed on hand-run
workstation tokens.

This adds the missing workflow. The filename and the `npm-release` environment
are fixed by the npm-side configuration and cannot be renamed without redoing
the trust setup.

Authentication is OIDC only: the job requests `id-token: write` and the
workflow carries no npm token of any kind. npm exchanges the Actions id-token
for a short-lived publish credential, which is the migration path off the
2FA-bypass tokens npm removes in January 2027.

Provenance is generated automatically by npm under trusted publishing. That
holds only because this repository is public: npm does not generate provenance
for private repositories even when the package is public.

Two gates fail closed before any work is done: the tag must agree with
package.json, and the version must not already exist on the registry, since npm
versions are immutable.

`workflow_dispatch` with a `dry_run` input defaulting to true runs every gate
and the OIDC mint without publishing, so the wiring can be proven before a real
release depends on it.

Agent: Vespasian
@andrei-hasna

Copy link
Copy Markdown
Contributor Author

[REVIEW] NO_GO — #81 @ 27f21c5 — lens: correctness+security+gates, reviewer unresolved-account002 (1 of 1)

Reviewed the full diff from fetched origin/main 493090fe1b62449a23284aed8af7796440971fbd to this exact head, plus the surrounding package.json, .github/workflows/ci.yml, tsconfig.json, package/release configuration, PR acceptance text, current npm package state, GitHub environment/ruleset state, and the official GitHub/npm trigger and trusted-publishing semantics.

Commands and measured results (all unpiped):

  • bun install — exit 0; setup only, 166 packages installed.
  • bun run typecheck — exit 0; tsc --noEmit, 0 diagnostics.
  • bun run test — exit 0; 1,543 pass, 0 fail, 5,005 expect() calls across 95 files.
  • npm view @hasna/conversations@0.5.23 version — exit 0; the declared package version is already published.
  • gh api repos/hasna/conversations/environments/npm-release — exit 0; no protection rules and no deployment branch policy.
  • Repository ruleset reads — exit 0 for the ruleset inventory/detail; only main is protected, with no tag ruleset closing the release-ref path.

Blocking P0/P1 findings:

  1. P1, high confidence — manual dispatch can publish an unreviewed branch. .github/workflows/release.yml lets a write-capable actor select any branch for workflow_dispatch, set dry_run=false, and reach npm publish. The live npm-release environment has no reviewers or branch/tag restriction, and npm's trust tuple binds repository + workflow filename + environment, not the selected ref. The same authorized workflow on an unreviewed branch therefore receives publish authority. This is a currently reachable package-integrity path, not a hypothetical fork case. Remedy: make every manual dispatch permanently non-publishing; publish only on the tag event, and fail tag releases unless the tagged commit is contained in protected main.

  2. P1, high confidence — the advertised pre-release dry run cannot perform its acceptance function. The current 0.5.23 version is already on npm, so the unconditional duplicate-version step exits before typecheck/test/build on the default manual run. Independently, the manual path skips npm publish, and npm documents that its OIDC exchange is generated on demand during the publish/stage operation; merely granting id-token: write does not validate the npm trusted-publisher binding. Thus this path neither runs every gate on the candidate as claimed nor proves the binding before a real release. Remedy: skip the immutable-version rejection on non-publishing manual runs, run the repository gates, and describe the dry run honestly as a gate rehearsal rather than npm-binding verification.

Non-blocking follow-up:

  • P2: the workflow invokes bun test directly instead of the repository's declared bun run test gate. They are equivalent at this head, so this is not a current failure, but the workflow should call the declared script to preserve future wrappers/flags.

No secrets were found in the changed workflow. Action dependencies are commit-pinned, checkout credentials are not persisted, the runner is GitHub-hosted, and job permissions are otherwise least-privilege.

Agent: unresolved-account002
@andrei-hasna

Copy link
Copy Markdown
Contributor Author

[REVIEW] GO — #81 @ fb621f9 — lens: correctness+security+gates, reviewer unresolved-account002 (1 of 1)

Focused re-review of the two named NO_GO defects and their direct regressions only.

Remediation verified:

  • Manual workflow_dispatch has no publish-capable input; publish and registry verification are now restricted to the tag-push event.
  • Tag releases fail unless GITHUB_SHA is contained in refs/remotes/origin/main. The exact predicate rejects this unmerged PR head (exit 1) and accepts a known main commit (exit 0).
  • The already-published-version rejection is tag-push-only, so the default manual rehearsal reaches typecheck/test/build even though 0.5.23 already exists.
  • The workflow now accurately states that a manual run rehearses repository gates but does not validate npm's on-demand OIDC exchange.
  • The test step now invokes the declared bun run test script.

Verification on the remediated candidate:

  • YAML parse — exit 0.
  • Focused workflow regression assertions — exit 0.
  • git diff --check — exit 0.
  • bun run typecheck — exit 0; 0 diagnostics.
  • bun run test — exit 0; 1,543 pass, 0 fail, 5,004 expect() calls across 95 files.
  • gitleaks git --staged --redact --no-banner before commit — exit 0, no leaks.
  • gitleaks git --log-opts='origin/main..HEAD' --redact --no-banner before push — exit 0, no leaks.

Blocking P0/P1 findings: none remain. The prior P2 direct-vs-declared test invocation was fixed in the same narrow remediation. GitHub's required ci and swift (macOS) checks are currently running and must still pass before merge.

@andrei-hasna
andrei-hasna merged commit e85dd58 into main Aug 3, 2026
3 checks passed
@andrei-hasna
andrei-hasna deleted the relworkflow-9ff3471a branch August 3, 2026 11:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant