Skip to content

feat(release): add controlled stable tag creation - #317

Draft
davida-ps wants to merge 3 commits into
davida-ps/stable-tag-policy-v1from
davida-ps/controlled-tag-creation-v1
Draft

feat(release): add controlled stable tag creation#317
davida-ps wants to merge 3 commits into
davida-ps/stable-tag-policy-v1from
davida-ps/controlled-tag-creation-v1

Conversation

@davida-ps

@davida-ps davida-ps commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

User description

Summary

  • replace the historical tag-triggered release entry point with a controlled, manual stable-release workflow
  • add an environment-gated controller that validates protected-main provenance, pinned tag-ruleset state, and final SemVer before creating an annotated tag
  • mint a repository-scoped GitHub App token with only contents: write for tag-object/ref creation, then use GITHUB_TOKEN separately to dispatch the release workflow
  • make retries idempotent by binding and re-reading the exact repository, package, version, commit, attempt, ruleset, App, tag object, and canonical tag message
  • convert scripts/release-skill.sh into review-branch preparation only; it no longer creates, pushes, force-moves, deletes, or publishes tags/releases
  • add an adversarial fake-API test matrix and update affected release/installability/advisory tests

Why

The previous pipeline could enter release behavior from public tag pushes and mixed preparation with publication mutations. The lifecycle design requires RC and beta work to stay private in labs and permits public release only for a reviewed final version through one auditable controller.

Renaming the old workflow path also closes historical workflow_dispatch access to that file. The new release workflow independently verifies the exact annotated-tag tuple before signing or publication jobs can start.

Security properties

  • no push.tags release trigger
  • protected clawsec-stable-release environment gate and a second enable-switch check after approval
  • least-privilege App token for tag writes; no Administration permission
  • separate Actions token for downstream dispatch
  • active repository tag-ruleset ID and server updated_at are bound into provenance
  • no tag update/delete/force path; lightweight and nested tags fail closed
  • obsolete return_run_details is absent from the current 2026-03-10 dispatch body, with an exact-body regression assertion

Operator prerequisites before enabling

This PR does not configure or enable production release settings. An operator must separately configure the protected environment, dedicated GitHub App, private key secret, ruleset ID and exact updated_at, verify the App is the sole ruleset bypass actor, and only then set CLAWSEC_CONTROLLED_RELEASE_ENABLED=true.

Remote validation

Validated on davida@20.14.133.241 in /tmp/clawsec-controlled-tag.H2JlJT/source after SHA-256 parity checks against every local changed file.

  • focused controlled-tag, stable-policy, release-workflow, installability, install-doc, and advisory-mirror tests
  • every scripts/test-skill-*.mjs test
  • complete advisory-feed-tests command surface, including NanoClaw v2 and PicoClaw consumers, Pages checksum, and traffic archive tests
  • ClawSec Suite, Hermes attestation guardian, and OpenClaw audit watchdog CI test jobs
  • workflow YAML parsing and Node syntax checks
  • npm ci (422 packages, 0 vulnerabilities)
  • full ESLint with zero warnings
  • npx tsc --noEmit
  • npm run build
  • executable-mode verification for scripts/release-skill.sh

The remote image lacks native zip; release simulations used the previously verified compatibility wrapper with SHA-256 472456191504d886c5871da3e900797512ab9266b8426d736c6a0f48248046b0.

Two independent remote reviews approved the exact tested candidate. No tag, GitHub Release, skill-store publication, merge, or repository-setting change was performed.

Stack

This draft PR targets davida-ps/stable-tag-policy-v1 and should be rebased or retargeted only after the prerequisite stable-tag-policy PR lands.


Generated description

Below is a concise technical summary of the changes proposed in this PR:
Add a controlled stable-release workflow that verifies protected-main provenance, tag-ruleset state, and final SemVer before creating an annotated release tag and dispatching publication from controlled-skill-release.yml and controlled_tag_creation.mjs. Refactor release-skill.sh into review-branch preparation only and update the release, installability, and advisory tests to match the new flow.

TopicDetails
Prep script Convert release-skill.sh into a review-branch prep helper that only updates and commits version metadata, then refresh its installability and workflow coverage tests.
Modified files (3)
  • scripts/release-skill.sh
  • scripts/test-skill-install-docs.mjs
  • scripts/test-skill-release-script-installability.mjs
Latest Contributors(2)
UserCommitDate
David.a@prompt.securityfix(release): make pre...July 23, 2026
david.a@prompt.securitychore(release): bump a...June 23, 2026
Stable release flow Introduce a manual controller workflow and controlled_tag_creation.mjs to validate release provenance, mint the tag with a least-privilege App token, and dispatch publication only after re-verifying the exact tuple.
Modified files (7)
  • .github/workflows/controlled-skill-release.yml
  • .github/workflows/create-skill-release-tag.yml
  • scripts/ci/controlled_tag_creation.mjs
  • scripts/test-advisory-mirror-propagation.mjs
  • scripts/test-skill-controlled-tag-creation.mjs
  • scripts/test-skill-release-workflow.mjs
  • scripts/test-skill-stable-tag-policy.mjs
Latest Contributors(1)
UserCommitDate
David.a@prompt.securityfix(release): make pre...July 23, 2026
Review this PR on Baz | Customize your next review

Comment on lines +15 to +17
export const CONTROLLED_TAG_SCHEMA = "clawsec.controlled-tag/v1";
export const CONTROLLED_RELEASE_WORKFLOW = "controlled-skill-release.yml";
export const REQUIRED_TAG_RULESET_PATTERN = "refs/tags/*-v*";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale enforcement workflow reference

The wiki automation module still points enforcement at .github/workflows/skill-release.yml, but controlled release now dispatches controlled-skill-release.yml, so operators will look at the wrong workflow — should we update the docs to reference controlled-skill-release.yml?

Severity

Want Baz to fix this for you? Activate Fixer You can also update your AI coding guidelines based on this comment by apply pr to [branch name]

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
scripts/ci/controlled_tag_creation.mjs around lines 15-17, the code defines
CONTROLLED_RELEASE_WORKFLOW as "controlled-skill-release.yml" and dispatches that
workflow, but the wiki automation/docs still reference
".github/workflows/skill-release.yml". Update the wiki automation module (or any
documentation/scripts that hardcode the old workflow filename) to use
"controlled-skill-release.yml" instead, ensuring the referenced path and workflow name
match the constant. After updating, verify there are no remaining references to
"skill-release.yml" for this controlled-tag dispatch flow.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Commit 2b0bf7f addressed this comment by updating workflow-related documentation/tests to reference controlled-skill-release.yml instead of the stale skill-release.yml path.

Comment on lines +173 to +177
function parseSkillMarkdownVersion(markdown, source) {
const normalized = markdown.replaceAll("\r\n", "\n");
if (!normalized.startsWith("---\n")) {
fail(`${source} must start with YAML frontmatter`);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SKILL.md parsing drifts across tools

The new controller reimplements the SKILL.md frontmatter check already in scripts/ci/simulate_skill_tag_release.mjs:143-159 and scripts/ci/generate_skill_release_trust_packet.mjs:60-77, so delimiter, CRLF, or version-rule changes can make the release simulator, trust-packet generator, and controller disagree on valid release markdown — should we centralize this in a small readSkillMarkdownVersion() helper?

Severity

Want Baz to fix this for you? Activate Fixer

Comment thread scripts/ci/controlled_tag_creation.mjs Outdated
Comment on lines +486 to +490
const existing = await readTagRecord(client, repository, plan.tag, { allowMissing: true });
if (existing) {
verifyTagRecord(existing, plan);
return { ...plan, ...existing, ...rulesetProof, operation: "resumed" };
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tag tuple checks duplicated three times

readTagRecord() plus verifyTagRecord() is duplicated in verifyControlledReleaseTuple() and preflightControlledTag(), so any change to the annotated-tag contract or resume/error handling has to be kept in three places and the create/resume/preflight paths can drift — should we wrap that readback + assertion in a small helper like loadVerifiedTagRecord() and keep branch-specific behavior at each call site?

Severity

Want Baz to fix this for you? Activate Fixer

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Commit 2b0bf7f addressed this comment by introducing loadVerifiedTagRecord() and using it across create/resume, verification, and preflight paths, eliminating the duplicated read-and-verify logic.

Comment thread scripts/release-skill.sh Outdated
Comment on lines +77 to +88
if ! jq --arg version "$VERSION" '.version = $version' \
"$SKILL_PATH/skill.json" > "$TEMP_DIR/skill.json"; then
echo "Error: failed to update $SKILL_PATH/skill.json." >&2
exit 1
fi
mv "$TEMP_DIR/skill.json" "$SKILL_PATH/skill.json"
FILES_TO_STAGE+=("$SKILL_PATH/skill.json")

# Update any hardcoded version URLs in skill.json (openclaw.feed_url pattern)
if jq -e '.openclaw.feed_url' "$SKILL_PATH/skill.json" >/dev/null 2>&1; then
echo "Updating openclaw.feed_url to use tag $TAG..."
if ! jq --arg tag "$TAG" '.openclaw.feed_url = (.openclaw.feed_url | gsub("/[^/]+-v[0-9.]+(-[a-zA-Z0-9]+)?/"; "/\($tag)/"))' "$SKILL_PATH/skill.json" > "$TEMP_DIR/skill.json"; then
echo "Error: Failed to update feed_url in skill.json"
if ! jq --arg tag "$TAG" \
'.openclaw.feed_url = (.openclaw.feed_url | gsub("/[^/]+-v[0-9.]+(-[a-zA-Z0-9.]+)?/"; "/\($tag)/"))' \
"$SKILL_PATH/skill.json" > "$TEMP_DIR/skill.json"; then

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interrupted runs leave dirty repo

This helper moves skill.json, SKILL.md, and other markdown files into the working tree before staging/commit, so a failure after the first mv leaves partial version bumps on disk and the next run trips git status --porcelain=v1 --untracked-files=all, which means release prep can't be safely retried after an interrupted run. Should we keep the rewritten files in the temp dir until everything succeeds, or add a rollback trap?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
scripts/release-skill.sh around lines 77-133 (the block that updates skill.json and
SKILL.md, and the subsequent loop that rewrites other *.md files), the script currently
applies changes to the working tree via mv back into $SKILL_PATH before all edits and
the commit succeed. Refactor so that rewritten files stay in $TEMP_DIR until every
transformation and verification step has completed successfully, then do a single
“apply” phase that replaces the originals atomically (or, alternatively, create
backups of every file you overwrite and add an EXIT/ERR rollback trap that restores
originals on failure). Ensure the error path restores all modified files so the script
can be safely retried after an interrupted run, without tripping the global clean-tree
check at lines 66-69.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Commit 2b0bf7f addressed this comment by keeping rewritten files in the temporary directory until preparation completes, then applying them in one phase. It also added backups and an EXIT trap to restore modified files if staging or committing fails.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant