Skip to content

chore(release): v0.3.0 - #9

Merged
SHcommit merged 63 commits into
masterfrom
release/0.3.0
Sep 2, 2026
Merged

chore(release): v0.3.0#9
SHcommit merged 63 commits into
masterfrom
release/0.3.0

Conversation

@SHcommit

@SHcommit SHcommit commented Sep 2, 2026

Copy link
Copy Markdown
Owner

Summary

Release v0.3.0: audit-driven Critical/High/Medium hardening pass, supply-chain release attestation, ADR-0012..0016 documenting this release's architectural decisions, and a cross-platform fix for a Windows/Python-3.9-only path-containment bug caught by this PR's own CI run.

See changelog.md's ## v0.3.0 section for the full list.

Test plan

After merge: tag v0.3.0 on master and push the tag to trigger .github/workflows/release.yml (tests, packaging, checksum, GitHub Artifact Attestation, GitHub Release).

🤖 Generated with Claude Code

SHcommit and others added 30 commits August 31, 2026 16:59
…backlog

Full 8-domain/24-criteria enterprise audit of the ADR Toolkit, plus a
priority-ordered backlog in improvements.md (scoped to exclude domains 1/5
and work happening in other worktrees: agy adapter, auto version sync,
README prose). handoff.md points to the implementation plan
(docs/superpowers/plans/2026-09-01-critical-hardening.md, gitignored by
convention) and lists per-task commit messages so a resumed or different
session can tell what's already done from git log alone.
New core/atomic_io.py: atomic_write_text() (temp-file + os.replace, so a
mid-write crash never leaves a torn file) and adr_directory_lock() (a
cross-process advisory lock -- fcntl on POSIX, msvcrt on Windows) for
serializing ID allocation + writes. Not yet wired into any command.
Wraps ID allocation + existence check + write in atomic_io.adr_directory_lock
and replaces the direct write_text with atomic_io.atomic_write_text. The
dry-run path stays outside the lock entirely (it must not create adr_dir or
a lock file, per existing dry-run tests) and does an unprotected preview
instead -- a race there is harmless since nothing is persisted.

Reproduced the race first: 20 concurrent `create` calls produced only 13
unique ADR IDs before this fix.
Same shape as the create.py fix, plus one refinement: schema validation
runs once against a preview ID before ever touching disk (not even to
create exceptions_dir or a lock file), since validity never depends on
which sequential number gets assigned. This satisfies the existing
"SCHEMA_ERROR must not create exceptions_dir" test as well as dry-run's
"must not create anything" test. The lock then wraps only the final ID
allocation + atomic write.

Reproduced the race first: 20 concurrent `exception` calls produced only
18 unique EXC IDs before this fix.
Wraps the whole two-file update in atomic_io.adr_directory_lock and
replaces both write_text calls (and the rollback write) with
atomic_write_text, so a crash never leaves either file torn.

Two existing tests (test_supersede_rolls_back_old_file_when_new_file_write_fails,
test_supersede_double_write_failure_reports_inconsistent_state_not_silent)
monkeypatched Path.write_text directly to simulate a write failure; that
seam no longer exists once writes go through atomic_io, so both were
retargeted to monkeypatch supersede.atomic_io.atomic_write_text instead,
preserving their original intent and assertions unchanged.
conflict._guarded_search wraps regex.search with a 0.25s SIGALRM-based
timeout on POSIX (Windows has no SIGALRM and runs unguarded there --
tracked as a known gap, not a regression). RegexTimeout subclasses re.error
so commands/check.py's existing `except re.error` handling downgrades a
timeout to a BAD_CONSTRAINTS warning without any change needed there.

Verified against a classic catastrophic-backtracking pattern ((a+)+$)
that would otherwise hang; the guard interrupts it in well under 1s.
core/rendering.py gains safe_md_link_text(), which escapes \, [, ], ( and )
and collapses embedded newlines. commands/index.py's four
[id — title](filename) link sites now route the title through it, so an
ADR title like "foo](http://evil.example)[bar" can no longer split the
generated docs/decisions/README.md into two links, one pointing off-repo.

This is a fix to the README *generator code*, unrelated to hand-authored
README prose being worked on elsewhere.
New core/telemetry.get_logger(operation) returns a LoggerAdapter that
writes JSON Lines to stderr (level, operation, correlation_id, message,
and exception_type on exceptions). adr.py's global exception handler logs
via logger.exception() and includes the same correlation_id in the stdout
JSON error response, so a failure reported by an agent/CI can be matched
back to its stderr log line. Default level is WARNING (quiet on success);
ADR_TOOLKIT_LOG_LEVEL overrides it. stdout's pure-JSON contract is
unchanged except for the additive correlation_id field.
improvements.md's Critical section is cleared (resolved work now lives in
changelog.md's Unreleased section + git history, per this file's own
convention). handoff.md records the 7 commits that made up this pass, the
2 real regressions TDD caught and how they were fixed, and the remaining
unscheduled High/Medium backlog for a future session to pick up only with
explicit owner direction.
feat(adapters): enhance Antigravity CLI plugin manifest, version sync, and CI safeguards
resolve_from_root now raises PathEscapesRootError when a relative path
(e.g. --dir ../../etc/cron.d) would resolve outside the given root. An
absolute path is left unchanged, as before -- it's the caller's own
explicit choice, with no relative containment to check.
Measured baseline before adding the gate: 93.32% branch+statement coverage
across skills/adr-toolkit/scripts. --cov-fail-under=85 leaves real headroom
rather than being a guessed threshold. release.yml is untouched.
New core/contracts.py defines TypedDicts (CommandError, BaseResult,
ErrorResult, CreateResult) for command JSON output shapes. A new
type-check CI job runs `mypy --strict` over the fully-typed core modules
(atomic_io, telemetry, contracts) -- fixed 3 real errors mypy found in
atomic_io.py/telemetry.py to get there (missing return type on the
contextmanager generator, an unnarrowed Optional in exc_info[0], and an
unparameterized generic LoggerAdapter).

Command *arguments* stay untyped (argparse.Namespace resists TypedDict
without a larger refactor) -- extending strict typing into the 16 command
modules is deliberately out of scope for this pass.
adr.py --diagnostic <operation> adds an elapsed_ms field to the JSON
result via time.perf_counter(). Must precede the operation name (argparse
subparsers can't see a flag registered only on the parent parser). Omitted
by default -- stdout's JSON shape is unchanged unless requested.
Forks a child that pauses right before os.replace() (after the temp file
is written, before the rename), SIGKILLs it there, and asserts the target
file still holds its original content -- an OS-level proof of the
guarantee atomic_write_text already provides, not a simulated exception.
Skipped on Windows (os.fork is POSIX-only).
New scripts/adapter_sdk.py (repo-root tooling, same category as
sync_version.py) validates the two fields every manifest-based adapter
shares: name and description, both required non-empty strings. All 4
manifest-based adapter test files (Claude, Codex, Gemini CLI, Antigravity)
now assert their real manifest passes it, loaded via importlib.util the
same way test_sync_version.py already works around the scripts/ vs
skills/adr-toolkit/scripts/ naming collision. adapters/generic/ has no
manifest and is unaffected.
improvements.md's Critical section is gone and High now holds only the 2
items deferred to another worktree. handoff.md records all 13 commits
across both the Critical and High-priority passes, the naming-collision
and dry-run-side-effect gotchas discovered mid-session, and the remaining
unscheduled Medium backlog for a future session to pick up only with
explicit owner direction.
feat(ci): add pre-push hook to prevent direct pushes to protected branches
…ape errors

New core/errors.py's AdrToolkitError(Exception) is now the base for
ConfigError, FrontmatterError, ConstraintsError, InvalidTransitionError,
GitPathsError, and PathEscapesRootError, each carrying a stable error_code
matching the string already used at its call sites. No call site anywhere
catches these by their old ValueError/RuntimeError base, only by name, so
this is a safe change (verified via grep before starting).

Bundled fix: PathEscapesRootError (added in the prior High-priority pass)
was never actually caught anywhere -- a rejected path escape fell through
to adr.py's generic INTERNAL_ERROR instead of a specific code, unlike
every other domain exception in this codebase. New
resolve_from_root_or_error() wraps resolve_from_root() and is now used at
all 7 call sites (create, exception, index, validate, check, init, and
graph's two sites), returning a proper PATH_ESCAPES_ROOT error.
Cross-checks schemas/adr.schema.json and schemas/exception.schema.json's
required-field lists and enum values against core/schema.py,
core/exceptions.py, core/lifecycle.py, and core/locale.py. Deliberately
stdlib-only -- adopting the `jsonschema` library, as the audit report
originally sketched, would trade away this project's zero-dependency
design for a moderate documentation-drift risk. Verified the test has
teeth by temporarily removing a required field from the schema file and
confirming it fails (reverted before committing).
SHcommit and others added 25 commits September 1, 2026 13:17
…on.md

Sourced from docs/enterprise-adoption.md §4/§6-9 (a separate governance/
adoption-maturity report, distinct from the code/architecture audit).
Three of the four items are precondition-gated on real-world facts (repo
going public, 2+ qualified maintainers, 2+ repositories existing) rather
than blocked by missing code -- flagged accordingly so a future session
doesn't try to "implement" a GitHub ruleset change or multi-repo tooling
against a single private repo. The fourth (adoption-metrics collection
from existing ADR/exception frontmatter) has no such precondition and is
flagged as the one actually startable item in this tier.

Also updated the Done section's branch/test-count notes to reflect the
origin/develop merge completed this session.
…rlier miss)

The prior "Low priority" pass only pulled from docs/enterprise-adoption.md;
the actual ask was to also mine docs/adr-toolkit-audit-report.md's own
Low-risk findings. Reviewed all 8 of that report's Low badges: 4 were
"no action needed" or already resolved (6.1, 8.3, 8.4's PR-title-check is
now the merged-in pr-title-check job, 5.3's README-escaping inconsistency
was fixed by this session's safe_md_link_text work) and are noted as such;
the remaining 4 real gaps are now in improvements.md's ### Low section
alongside (separately sourced and labeled) the enterprise-adoption.md
items: CODEOWNERS doc for constraints: blocks, a trivial
proposed->deprecated lifecycle transition, moving CHECK's constraint lint
earlier to CREATE/STATUS time, and folding Antigravity into harness-parity
once agy gets a public registry (blocked, same as the other-worktree agy
work).
…ock review

- core/lifecycle.py: ALLOWED_TRANSITIONS["proposed"] now includes
  "deprecated", supporting withdrawing a proposal that never reached
  consensus (previously only accepted/rejected were reachable from
  proposed). docs/adr-toolkit-audit-report.md §2.5 5.1.
- CONTRIBUTING.md: documents that a constraints: block change needs
  sign-off from someone with authority over its affected_paths, since
  it's enforced policy text, not prose -- a process control in place of
  code sandboxing, per §2.2 2.1's reasoning (no third-party code executes
  here, so isolation isn't the applicable defense).
…time

New core/constraints.lint(body) wraps extract_constraints() and returns a
BAD_CONSTRAINTS warning instead of raising, so a typo in a constraints:
block surfaces as soon as the ADR is authored or accepted, rather than
silently going unenforced until CHECK happens to run against it later.

- create.py: lints the draft body on every path (dry-run and real write),
  added to a new "warnings" field alongside the existing response keys.
- status.py: lints only on transition to "accepted", since that's the
  status CHECK actually enforces constraints against -- other transitions
  return warnings: [] without parsing the body.
- core/contracts.py: CreateResult and StatusResult gain the warnings field
  to match.

docs/adr-toolkit-audit-report.md §2.5 5.2.
improvements.md's audit-report Low sub-group is down to 1 item
(Antigravity/harness-parity, blocked on an external precondition).
handoff.md records the new commits, the parallel Codex session working on
the adoption-metrics item in this same worktree/branch, and updates the
Next-step checklist to reflect that almost nothing remains open in this
worktree's own scope.
…tatically

New _reject_if_redos_prone() in core/constraints.py rejects a `pattern`
value at parse time if it contains a quantified group whose own body ends
in a quantifier (e.g. (a+)+, (a*)*, (x{1,3})+) -- the classic catastrophic-
backtracking shape. This is a static, string-level check, not a runtime
guard, so it works identically on every platform and closes the Windows
gap left by rules/conflict.py's SIGALRM-based timeout (POSIX-only,
confirmed unguarded on Windows per docs/adr-toolkit-audit-report.md
§2.2 2.3's Open Risk).

Scoped to forbidden_import/dependency_forbidden only -- required_path/
forbidden_path treat `pattern` as glob syntax via core/globs.py, which
can't produce catastrophic backtracking, so flagging it there would be a
false positive. Verified the real dogfooded ADR-0011 constraints block
(genuine regex patterns) still validates and checks cleanly, and that a
rejected pattern never reaches re.compile() (monkeypatched re.compile to
assert it's never called for a dangerous pattern).

This is a heuristic covering the single most common ReDoS shape, not a
full detector -- alternation-based patterns like (a|a)* are a different
dangerous shape and remain uncaught, noted in the code comment.
…xists

improvements.md and handoff.md updated for this session's work only
(the Windows static-complexity-linter item promoted from Open Risks).
Codex's already-committed adoption-metrics commits are acknowledged as
present in this branch but not itemized here, per the owner's explicit
request not to reflect that in-progress work from this session's docs.
…sync work

PR #6 (feature/agy-plugin-implements-2) and PR #7 (feature/add-githooks)
-- the "다른 워크트리" that improvements.md's 2 High items were deferred
to -- are both already merged into origin/develop and pulled into this
branch (0a0db8a). Re-checked both items against actual current code:

- 8.4 auto-version-direction review: genuinely done. sync_version.py and
  release.yml still do manual-only version bumps, no conflict with the
  audit's recommendation. Closed, moved to Done.
- Supply-chain signing: genuinely still unimplemented (release.yml has no
  checksum/signing step). Stays Open, stale "다른 워크트리 확인" framing
  removed since that worktree's work is already merged -- now startable
  here, though it touches the release pipeline so needs owner
  confirmation before starting.
- Antigravity/harness-parity Low item: re-verified still correctly
  blocked (agy still has no public registry per its README) -- confirms
  not everything tied to that merged worktree is automatically resolved.

Does not touch or itemize the parallel Codex session's adoption-metrics
work, per explicit instruction.
.github/workflows/release.yml now packages skills/adr-toolkit/ into a
version-named tarball, checksums it (SHA-256), and generates a Sigstore-
backed GitHub Artifact Attestation for it via actions/attest-build-
provenance@v2 -- keyless (OIDC-based), no private key to manage or
rotate. Both the tarball and its checksum are attached to the GitHub
Release.

Chose this over signing the git tag itself: every adapter (Claude Code
marketplace source:"./", Codex/Gemini CLI plugin installs, generic copy/
symlink) references the repo or skill folder directly rather than
downloading a packaged release, and tags in this project are created
locally by a human before the push that triggers this workflow -- CI
can't retroactively sign a tag that already exists. Attestation instead
ties provenance to the exact commit the tag points to, verifiable via
`gh attestation verify`, which covers the one real gap: someone grabbing
the archive off the GitHub Releases page instead of cloning.

SECURITY.md documents the verification commands (sha256sum -c +
gh attestation verify) and is explicit that the git-clone/adapter-install
paths verify via Git/GitHub history already, not this archive.

Verified locally: the tar+sha256sum packaging commands run correctly
against the real skills/adr-toolkit/ tree, and the modified YAML parses
without syntax errors. The actual OIDC/attestation exchange can only be
exercised by a real tag push through GitHub Actions, which this session
did not do (release pipeline; requires explicit owner action to trigger).

docs/adr-toolkit-audit-report.md §2.2 2.2.
Marks the release-pipeline attestation work (18d4662) done in
improvements.md/handoff.md and records the option analysis in a
troubleshooting worklog, without touching the concurrent adoption-
metrics entries already present in both files.
Documents atomic writes + directory locking, the two-stage ReDoS guard,
typed result contracts + mypy --strict, and structured JSON logging with
correlation IDs -- each grounded in the actual commits and test evidence
from this session, per the troubleshooting-worklog skill format.
Uses the ADR toolkit itself to document the Critical/High-priority
hardening work: atomic writes + directory locking, the two-stage ReDoS
guard, typed result contracts + mypy --strict, structured JSON logging
with correlation IDs, and release artifact attestation. Each ADR passed
significance scoring (recommended band) and `related` conflict checks
before creation; all 16 ADRs now validate and the index/graph are
regenerated.

Also removes docs/worklogs/ (superseded by these ADRs and backed up
externally) and gitignores the .adr-toolkit.lock runtime mutex file,
which was never meant to be committed.
Rolls changelog.md's Unreleased section into a v0.3.0 entry and
propagates VERSION to every manifest via scripts/sync_version.py.
README.md needed no changes (no version strings, and this release's
hardening work doesn't add new user-facing operations); examples/
verified clean with no drift via scripts/verify_examples.py.
CI on PR #8 caught a real, Windows/Python-3.9-only failure: `adr.py init
--dir docs/decisions` (and every other resolve_from_root call site)
rejected a plainly-under-root path with PATH_ESCAPES_ROOT, only on
windows-latest with Python 3.9 -- not 3.12, and not on ubuntu/macOS at
any version. `docs/decisions` doesn't exist on disk yet when INIT
scaffolds it, and Path.resolve() on a non-existent path has inconsistent
cross-version behavior on Windows.

Fix: check containment by lexically normalizing the joined path
(os.path.normpath, no filesystem access) against the already-resolved
root, instead of calling .resolve() on a path that may not exist yet.
The threat model here is lexical `..` traversal, which normpath handles
without ever touching the filesystem. All 13 repository_paths/path-escape
tests plus the full suite (541) still pass locally.
feat: audit-driven hardening pass (Critical/High/Medium) + v0.3.0
@SHcommit SHcommit changed the title release: v0.3.0 chore(release): v0.3.0 Sep 2, 2026
@SHcommit SHcommit closed this Sep 2, 2026
@SHcommit SHcommit reopened this Sep 2, 2026
@SHcommit
SHcommit merged commit 9f132ba into master Sep 2, 2026
38 of 40 checks passed
@SHcommit
SHcommit deleted the release/0.3.0 branch September 2, 2026 00:41
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