Adopt updated policies and simplify release publication

This commit is contained in:
2026-09-26 14:25:32 +00:00
parent f33af20524
commit 537bddf40c
11 changed files with 1301 additions and 440 deletions

View File

@@ -1 +1,4 @@
Please review `docs/development.md` for initial orientation in this repository and follow its task-specific reading guide.
Review and follow `docs/policy/agents.md` before recommending, planning, or
implementing changes to this repository.

View File

@@ -7,12 +7,13 @@ framework implementation work belongs in Promptkit.
## Initial Orientation
Before starting work:
Before recommending, planning, or implementing work:
1. inspect the working tree and preserve unrelated changes;
2. read the [architecture policy](policy/architecture.md);
3. follow the task-specific contracts and internal documents below; and
4. inspect the relevant implementation and tests before changing them.
1. read the [agent policy](policy/agents.md);
2. inspect the working tree and preserve unrelated changes;
3. read the [architecture policy](policy/architecture.md);
4. follow the task-specific contracts and internal documents below; and
5. inspect the relevant implementation and tests before changing them.
Also read the [documentation policy](policy/documentation.md) before changing
documentation and the [testing policy](policy/testing.md) before changing
@@ -31,7 +32,7 @@ tests.
| OpenAI-compatible outbound behavior or timeout layering | [Promptkit integration contract](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.9.0/docs/integrations/openai-compatible-chat.md) |
| Subprocess behavior | [Subprocess integration](integrations/subprocess.md) and [CLI contract](cli.md) |
| Runtime operation or recovery | [Operations](operations.md) |
| Release packaging or publication | The [release procedure](release.md), [hosted release workflow](../.woodpecker/release.yml), and [architecture policy](policy/architecture.md) |
| Release packaging or publication | The [release policy](policy/release.md), [release procedure](release.md), [hosted release workflow](../.woodpecker/release.yml), and [architecture policy](policy/architecture.md) |
| Examples or copyable assets | The owning Scriptorium contract, the relevant [Promptkit format contract](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.9.0/docs/formats.md), and the related files under `examples/` |
| Architecture decisions or future work | The [documentation policy](policy/documentation.md), relevant accepted ADRs, and relevant roadmap documents |

77
docs/policy/agents.md Normal file
View File

@@ -0,0 +1,77 @@
# Agent Policy
**Base Policy Version:** 1.1
**Last Updated:** 2026-09-26
## Purpose
Agents working in this repository must read and adhere to the following house rules.
## Implementation Discipline
**KEY PRINCIPLES:** Write the simplest idiomatic code that satisfies the user's
request. Do not implicitly strengthen requirements, restrictions, or guarantees
beyond what the user actually requested.
**Keep implementation machinery proportionate.** Use straightforward,
idiomatic code and introduce only the machinery needed to support the requested
behavior and flexibility. Optimize for conceptual simplicity and
maintainability, not for the fewest lines, fewest options, or narrowest product
surface.
**Do not invent restrictions.** Do not reject, prohibit, constrain, cap,
harden, or otherwise narrow behavior unless required by the user, an explicit
repository contract, or an unavoidable correctness constraint. Preserve
operator and caller choice where policy has not been established. If an
additional restriction appears advisable, present it as a decision rather than
silently implementing it.
**Do not invent guarantees.** Do not strengthen public contracts, guarantees,
or invariants beyond those expressly requested by the user.
**Use the standard library.** For example: in almost every case, the best
approach to copying files is to use the standard library. Writing custom code to
implement power-loss protection at the application level, or to guard against
bad actors with privileged and concurrent access to the host's filesystem,
is wasteful, unnecessary, and not idiomatic. Stick to the simple, standard, and
idiomatic approach unless another path is expressly requested by the user.
**An implementation detail is not repository policy.** Tests and documentation
must not elevate incidental behavior into a durable guarantee or restriction
unless that behavior was expressly selected as part of the contract.
## Git Commit Policy
By default, create a commit after completing each coherent unit of work, using a
concise, plain-English message describing what changed. This policy is standing
authorization to create those local commits; a separate user request is not
required. An explicit user instruction to leave work uncommitted takes precedence.
A unit of work is a focused, reviewable outcome, including its relevant tests and
documentation. Do not commit after every intermediate edit or create empty commits.
Complete the appropriate validation before committing. If work is incomplete or
validation is blocked, report that state rather than presenting it as completed.
Before committing:
1. Run `git status --short` and review the relevant diff, including any staged
changes. Identify changes that predate the current unit of work.
2. Exclude secrets, local environment files, generated junk, and unrelated changes.
3. Stage only the files or hunks belonging to the completed unit. Use `git add .`
only when every included change belongs to that unit. Do not disturb unrelated
staged or unstaged work.
4. Review `git diff --staged` and run appropriate checks for the change. Do not
repeat checks already passed for unchanged content merely to create a commit.
5. Review `git log --oneline -n 10` and use
`git commit -m "<concise plain-English commit message>"`.
6. Report the commit identifier and any remaining uncommitted work.
If the unit cannot be safely separated from pre-existing work, leave it uncommitted
and explain the overlap rather than silently including that work. If a commit
fails, preserve the changes and report the failure; do not bypass hooks.
Default commit authorization does not authorize pushing, publishing, amending,
resetting, reverting, or rewriting history. Those actions require a separate user
instruction. Do not use `git commit -a` or `git commit --no-verify` to bypass the
staging and validation discipline above.

View File

@@ -1,7 +1,21 @@
# Architecture
# Architecture Policy
This document defines Scriptorium's current application architecture and
durable development boundaries.
**Base Policy Version:** 1.1
**Last Updated:** 2026-09-26
## Purpose And Ownership
This policy defines Scriptorium's purpose, responsibility and dependency
boundaries, established invariants, and non-goals. Concrete component inventory
and implementation mechanics belong in [internal documentation](../internal/overview.md);
public behavior belongs in its canonical contracts.
Adopted constraints govern future development without implying that every
capability they could govern exists or is planned. Keep proposed structure and
implementation status in roadmaps, and decision rationale in ADRs. Acceptance
of a decision is not proof of implementation. Do not turn incidental choices
into architectural invariants.
## System Shape
@@ -21,6 +35,16 @@ deployment policy.
The concrete package inventory is maintained in the
[internal overview](../internal/overview.md).
As established by [ADR 0004](../adr/0004-definition-boundary.md), Scriptorium
aims to support the complete set of features in valid Promptkit prompt and
profile definitions for its selected upstream version. Promptkit remains the
canonical parser and validator. Library-only features are considered
individually against the simple predefined-prompt execution model.
Maintain adapter boundaries with proportionate, idiomatic code. This approach
does not require an interface for every type, additional wrappers, or a fixed
directory tree for integrations that do not exist.
## Dependency Direction
```text
@@ -46,6 +70,10 @@ gitea.maximumdirect.net/eric/promptkit
The repository architecture guard enforces these import and removal
invariants.
Cross-project changes land and release in Promptkit before Scriptorium adopts
the tagged dependency. Do not commit Go workspaces, local replacements,
vendored Promptkit implementation, or unpublished dependency assumptions.
## Retained Boundaries
- `internal/adapter/cli` owns commands, flags, configuration precedence,
@@ -71,6 +99,13 @@ That reader denies file references without an artifact root, enforces the
configured byte limit, and applies Scriptorium's lexical root-containment rule.
The operating system still follows symlinks after the lexical check.
This boundary handles untrusted request references under the documented
deployment model; it is not a filesystem sandbox. Defending against a
privileged actor concurrently modifying the host filesystem is outside the
adopted scope, not a claim that such activity cannot occur. Do not infer
additional defenses or guarantees from hypothetical threats; raise unsettled
scope decisions under the [agent policy](agents.md).
The [HTTP API](../api.md) owns observable request outcomes, and
[operations](../operations.md) owns deployment permissions and root selection.
@@ -80,6 +115,14 @@ Scriptorium has no durable run-state store, checkpoint, cache, or resume
mechanism. Recovery is a new request after correcting inputs, configuration, or
environment.
Each request is self-contained from the caller's perspective, without
conversation or workflow state retained from completed requests. This does
not mean the process is internally stateless. Shared engines, backend capacity
and queue accounting, in-flight coordination, cancellation, and server
lifecycle resources are permitted and must be shared where necessary to
enforce configured backend limits across concurrent requests. Such state must
be concurrency-safe and does not create a durable user-workflow contract.
Adapters map Promptkit public error identities into CLI exits or HTTP statuses
without classifying by message text. Raw API keys are not accepted in
Scriptorium configuration, CLI arguments, or HTTP payloads, and resolved
@@ -102,5 +145,7 @@ secrets must not be emitted.
into Scriptorium.
- Do not move CLI, inbound HTTP, process, or deployment policy into Promptkit.
- Do not add durable workflow, archive, or resume behavior.
- Do not add application-managed prompt modification, follow-up message flows,
conversation management, or multi-step workflow orchestration.
Work that is not implemented belongs in `docs/roadmap/`.

View File

@@ -1,5 +1,9 @@
# Documentation Policy
**Base Policy Version:** 1.3
**Last Updated:** 2026-09-26
## Purpose
This policy assigns each documentation topic to one canonical owner. Its goal is
@@ -30,10 +34,14 @@ semantics.
Outside `docs/roadmap/`, documentation describes implemented behavior only.
Partial features may be described only to their implemented boundary.
ADRs are the narrow exception: an ADR may record an accepted architectural
decision before implementation, but acceptance must not be presented as proof
that the behavior exists. The roadmap owns implementation status and sequencing
until the decision is implemented. Current architecture, user, operator,
Architectural constraints may govern future changes without implying that the
components or capabilities they would govern already exist. This does not
permit describing a proposed system structure as implemented.
ADRs may record an accepted architectural decision before implementation, but
acceptance must not be presented as proof that the behavior exists. The roadmap
owns implementation status and sequencing until the decision is implemented.
Current architecture, user, operator,
integration, and internal documentation are updated when the behavior lands.
### Audience And Detail
@@ -65,13 +73,15 @@ secret values.
| --- | --- | --- | --- |
| Product orientation and minimal end-to-end quickstart | `README.md` | What this project is, why it is useful, one shortest successful invocation, and links onward. | Complete command reference, configuration reference, operational procedures, implementation detail. |
| Contributor entry point | `docs/development.md` | Task-oriented reading guide, minimal contributor orientation, baseline validation commands, and links to canonical docs. | Package inventory, architecture rules, subsystem behavior, and detailed change recipes, which belong in the relevant internal component document. |
| Current application architecture | `docs/policy/architecture.md` | System shape, normative ownership, dependency direction, architectural boundaries, invariants, safety properties, and non-goals. | Concrete package inventory, implementation mechanics, contributor procedures, decision history, future work. |
| Application architecture policy | `docs/policy/architecture.md` | System shape, normative ownership, dependency direction, architectural boundaries, invariants, safety properties, and non-goals. | Concrete package inventory, implementation mechanics, contributor procedures, decision history, future work. |
| Agent policy | `docs/policy/agents.md` | Agent implementation discipline, proportionality, idiomatic simplicity, local commit rules, and rules against inventing requirements, restrictions, guarantees, or repository policy. | Application contracts, architecture, testing policy, and contributor procedures. |
| Documentation organization | `docs/policy/documentation.md` | Documentation ownership, audience boundaries, maintenance rules, and ADR/document lifecycle. | Application architecture or product behavior. |
| Testing policy | `docs/policy/testing.md` | Test philosophy, risk-based sufficiency, test boundaries, doubles, coverage guidance, regression-test policy, and criteria for adding, rewriting, or deleting tests. | Subsystem behavior, application contracts, subsystem-specific test inventories, and implementation plans. |
| CLI contract | `docs/cli.md` | Commands, arguments, flags, invocation semantics, and exit codes. | End-to-end operating procedures, configuration field definitions, runtime filesystem layout, module implementation details. |
| Configuration contract | `docs/config.md` | Application discovery and precedence, source locations, server fields, render default, HTTP limits, and credential mapping. | Promptkit framework formats and defaults, complete example files, CLI syntax, runtime lifecycle, and implementation detail. |
| Operations | `docs/operations.md` | Runtime workflows, physical filesystem and state layout, output, cache, and debug handling, resume, cleanup, permissions, recovery, and operational limits. | CLI flag syntax, configuration field definitions, logical output schemas, implementation mechanics. |
| Release procedure | `docs/release.md` | Candidate validation, version and tag operations, hosted-workflow observation, and published-artifact verification. | Runtime operations, version-specific announcements, and complete application-interface contracts. |
| Release policy | `docs/policy/release.md` | Versioning and compatibility, release integrity and immutability, documentation requirements, proportionate gates, publication controls, source completion, and optional verification boundaries. | Repository-specific commands, installation guidance, historical summaries, and CI implementation details. |
| Release procedure | `docs/release.md` | Maintainer prerequisites, candidate preparation, publication command and guards, asynchronous delivery, optional diagnostics, and failure recovery. | General release requirements, runtime operations, historical announcements, and current application contracts. |
| Version-specific release notes | `docs/releases/` | Immutable release summaries, compatibility notices, and migration announcements for one published version. | Complete CLI, HTTP, configuration, operations, or dependency contracts. |
| Public HTTP contract | `docs/api.md` | Routes, authentication, media types, request and response schemas, status codes, pagination, caching, idempotency, rate limits, and HTTP retry semantics. | Client walkthroughs, upstream or downstream integration internals, implementation detail. |
| Consumer guidance | `docs/consumers/` | Choosing between Scriptorium's executable interfaces and understanding consumer responsibilities. | HTTP wire semantics, CLI syntax, Promptkit's Go package, and internal implementation detail. |
@@ -103,6 +113,15 @@ runtime state and how to operate or recover the application. When a workflow
crosses these topics, choose the document that owns the task and link to the
other contracts.
### Releases
The release policy owns release requirements; the release procedure supplies
the commands that satisfy them. Release notes are historical summaries, not
current-state contract owners. The checked-in note at an immutable tag records
that release; current documentation changes with the behavior it describes.
Neither a procedure nor a note may weaken policy or defer required updates to
canonical contracts.
### Contracts And Implementation
Integration and API documents define externally observable shapes and
@@ -150,6 +169,11 @@ ADR; rejected product ideas belong in the roadmap.
## Maintenance
Every policy document records its underlying template version and local
last-updated date immediately below the title. Update the date when revising
local policy; change the base version only when adopting a newer template.
Do not retain template adoption instructions in the local policy.
When behavior changes, update its canonical owner in the same change. If
ownership moves, remove the old definition and replace it with a link where
navigation remains useful.

207
docs/policy/release.md Normal file
View File

@@ -0,0 +1,207 @@
# Release Policy
**Base Policy Version:** 1.1
**Last Updated:** 2026-09-26
## Purpose
This policy defines a proportionate workflow for validating and publishing an
intended source revision under a fresh version. Releases should require little
routine intervention, avoid repeated work, and preserve published contents.
The repository-specific commands belong in `docs/release.md`. Historical release
summaries belong in `docs/releases/`. The procedure must agree with the adopted
policy and link to it rather than introduce additional release requirements.
## Release Model And Completion
A source release identifies one exact committed source revision through an
immutable version tag and a version-matched, checked-in release note. The
primary release branch is `main`; the authoritative publication remote is
`origin`.
A successful push of the intended tag, with its source commit available on the
authoritative primary branch, completes the release. Publish `main` before
invoking the publication command; the command requires
that the remote branch exactly identifies the validated candidate and pushes
only the selected annotated tag.
Release commands must finish at successful publication. They must not wait for
or poll CI, require successful CI status, inspect downloadable artifacts, or
check consumer installation or Go module availability as completion gates.
A failed or ambiguous push requires resolving the authoritative remote state
before declaring success or retrying; successful pushes do not require a
separate remote read-back gate.
Scriptorium publishes Linux binaries and checksums asynchronously from the
tagged source. The procedure must identify those deliverables and the
responsibility of their automation. Published deliverables must be traceable
to that source and identify the same version.
Artifact delivery is a separate status. A successful tag push is not evidence
that artifacts are available. Delivery failures do not reverse source-release
success, and optional investigation must not delay release completion.
## Versioning And Compatibility
Stable releases use Semantic Versioning in the form `vMAJOR.MINOR.PATCH`, with
no leading zeroes. Prereleases are not part of the adopted release channel.
The selected version must be unused locally and on the authoritative remote.
Do not require queries to every configured remote or publication service.
A version known to have already been published must not be reused, even if the
normal guards do not inspect that channel. Adding a distribution channel
requires a deliberate update to the policy and procedure where needed.
For releases at or after `v1.0.0`:
- a major release may make intentional incompatible changes;
- a minor release may add backward-compatible behavior; and
- a patch release may make backward-compatible fixes.
Before `v1.0.0`, a minor release may change a documented public, operational,
configuration, data, integration, or compatibility contract when the release
note explains the impact and required consumer action. A patch release must
not intentionally break those contracts within its minor line.
Additional compatibility promises and deliberate exceptions belong in the
adopted policy or canonical contract documentation. Release notes explain
changes; they do not redefine current compatibility contracts.
## Release Documentation And Preparation
Every new release must include `docs/releases/<version>.md` in the tagged
source tree. Use the complete version tag as the filename and begin with a
title containing the product name and that exact version.
Include a short summary and any relevant compatibility impact or upgrade
instructions. Keep the note proportionate to the change. Fixed sections and
explicit no-action boilerplate are not required. Existing historical notes
need not be rewritten to adopt this format.
Keep exact current contracts in their canonical documents and update affected
documentation with the behavior change. Link to those documents from the note
where useful. A release note must not substitute for current documentation.
Use annotated tags with their message derived from the checked-in note rather
than requiring a separately authored account of the same release.
Version selection, release-note content, and retirement of completed roadmaps
are preparation responsibilities, not separate human approval gates. Preserve
planned and deferred work and repair affected links when retiring roadmaps.
Ordinary change review covers unintended files, credentials, and private data;
do not repeat it as a release-specific sign-off. Ignored local files are not
prohibited merely because they exist: they are not included in the tagged source.
## Candidate Validation
Provide one publication command that validates the final committed candidate,
checks publication state, and publishes the intended refs. A validation-only
mode or command may be provided for diagnosis, but must not be a required step
before publication repeats the same checks.
Run the complete deterministic, offline default test suite once for the final
candidate, following the [testing policy](testing.md), and verify the required
build targets. Select those targets for the application's actual support
commitments; native testing on each platform and access to particular hardware
are not default release prerequisites.
The selected test invocation includes the race detector because the HTTP
service shares engines and backend capacity state across concurrent requests.
This single invocation covers both ordinary assertions and race detection.
Build both declared Linux architectures with the hosted release flags to catch
cross-compilation and packaging failures; native test hardware for each target
is not required. Existing architecture tests enforce dependency boundaries.
Retain narrow source guards against committed workspaces, vendor trees, and
module replacements: these can otherwise make a passing local build depend on
unpublished or substituted framework code. Disable workspace discovery and
vendor mode during validation. These guards preserve the established
cross-project boundary; they do not require exact dependency-version audits or
module-metadata rewrites at release time.
Additional gates must address a concrete risk that the existing checks do not
adequately cover. Do not automatically require linting, race testing, module
hygiene audits, documentation audits, credential scans, example validation, or
expanded platform matrices solely because they are possible. Retain such
checks when their distinct protection justifies their cost. A one-time migration
check must not silently become a permanent release requirement.
Release tests must not require real credentials, mutable application services,
paid APIs, provider probes, or inference calls. The procedure describes
dependency preparation separately from offline validation.
Avoid duplicate validation across release phases. Successful checks for the
same final candidate need not be repeated solely to satisfy another phase;
any reuse must clearly apply to that exact candidate. Persistent validation
caches are not required. If the candidate changes, validate the new candidate.
Cheap publication-state checks may be repeated immediately before pushing
because local or remote state may have changed during validation.
Automate mechanical guards. Human review gates are strongly disfavored: add one
only through an explicit project-specific policy decision explaining the
concrete risk and why ordinary change review or automation is insufficient.
Do not add routine confirmations for checks the command can perform itself.
## Publication Guards
Automate the checks needed to publish the intended source under a fresh version:
- the version has the adopted format and is unused locally and on the
authoritative remote;
- the worktree and index are clean on the primary release branch;
- the source being published is the exact candidate that passed validation,
including its matching release note;
- the remote primary branch already identifies that exact candidate; and
- the tag identifies the validated candidate and only the intended refs are
published.
Fetch the authoritative branch and tag state as needed for these checks.
Do not force branch or tag updates to bypass a failed guard. Publish explicit
refs rather than all local tags. Do not require CI success, publication-service
availability checks, or a separate manual confirmation for each guard.
## Immutability And Failure Recovery
Published version tags and published contents are immutable. Do not move,
overwrite, recreate, or delete a published tag, or replace a published artifact,
checksum, signature, provenance statement, or release note under that version.
The checked-in note at the immutable tag is that release's historical record.
If validation fails before publication, correct and commit the candidate and
rerun the publication command. If a push fails or its outcome is ambiguous,
inspect the authoritative remote before retrying. Remove an unpublished local
tag only after confirming it was never published or used by a release channel.
An unavailable remote leaves publication status unknown; do not overwrite a tag
to resolve uncertainty.
Transient network, authentication, CI, or consumer-availability failures do not
by themselves require a new version. Asynchronous delivery may be retried from
the same immutable tag. Missing artifacts may be published later, provided
existing published contents are not replaced and the additions remain consistent
with the tagged source and any already-published checksum manifest. If that
cannot be achieved, publish a new version.
A defect requiring changes to published source or contents requires a corrected
commit, a new semantically appropriate version, a matching release note, and
the release procedure again. Leave the original release intact. A channel's
yank, deprecation, or advisory mechanism may warn consumers away from a defective
release without replacing its contents or reusing its version.
## Repository Release Procedure
Maintain `docs/release.md` as a short, executable maintainer runbook. It must
identify:
1. prerequisites, primary branch, and authoritative remote;
2. version and release-note preparation;
3. one publication command with its selected local checks and automated guards;
4. declared deliverables, required build targets, and automation responsibilities;
5. the successful-push completion point and separately reported delivery status;
and
6. failure handling, ambiguous-push recovery, and asynchronous delivery retries.
Use copyable commands where practical. Keep optional diagnostics and consumer
verification clearly optional. The procedure must not turn preparation work,
ordinary change review, or post-publication investigation into additional
release gates.

View File

@@ -1,5 +1,9 @@
# Testing Policy
**Base Policy Version:** 1.1
**Last Updated:** 2026-09-26
## Purpose
Our tests exist to make **incorrect changes expensive and correct changes cheap**.
@@ -8,29 +12,16 @@ We do not optimize for test count, line coverage, exhaustive isolation, or the f
## Every test has a cost
Testing is not an unqualified good. Every test imposes both an immediate cost and a continuing lifetime cost.
A test must be:
- written and reviewed;
- understood by future maintainers and coding agents;
- executed in local and CI workflows;
- diagnosed when it fails;
- updated when legitimate behavior changes;
- maintained as fixtures, APIs, and dependencies evolve; and
- removed or rewritten when it becomes redundant, brittle, misleading, or obsolete.
Tests also create cognitive and architectural friction. They can constrain refactoring, duplicate policy, slow feedback loops, add noise to failures, and cause harmless implementation changes to require unrelated edits across the suite.
Every test has an immediate and continuing cost: writing, review, execution,
diagnosis, fixture maintenance, and updates when legitimate behavior changes.
Tests can also constrain refactoring, duplicate policy, and add failure noise.
A test is warranted only when the confidence it provides justifies these costs.
Apply this cost-benefit analysis at two levels:
Evaluate that tradeoff at two levels:
1. **Per test:** What realistic defect does this test detect, how consequential would that defect be, and is that protection worth the test's lifetime cost?
2. **Across the suite:** Does this collection provide materially more confidence than a smaller, simpler suite would?
The preferred test suite is a **lean suite that provides sufficient confidence in the risks that matter, without redundant or low-value tests**. We seek sufficient confidence with the least unnecessary testing friction, not the fewest possible tests.
Some friction is intentional. Tests should make dangerous changes—such as breaking compatibility, corrupting data, violating security boundaries, or reintroducing subtle bugs—require deliberate review. They should not make ordinary internal changes needlessly expensive.
The cost of a test is not a reason to omit testing by default. Do not cite maintenance cost abstractly. When omitting a plausible test, be able to state why the protected failure is low-risk, already covered, obvious, reversible, or cheaper to detect elsewhere. For consequential, subtle, or difficult-to-observe behavior, the presumption should favor testing.
@@ -55,7 +46,7 @@ Control clocks, randomness, environment variables, and other process-global or m
## What deserves tests
Prioritize tests for:
To the extent the following features are present in the application, prioritize tests for:
1. Public and package-level contracts.
2. Domain rules and important invariants.
@@ -67,7 +58,18 @@ Prioritize tests for:
A package-level contract is behavior relied upon by another package or major collaborator, not every observable detail of a package implementation.
For behavior involving **data integrity, destructive operations, compatibility, security, concurrency, idempotency, or recovery**, presume that durable tests are required unless the behavior is already credibly protected at another layer.
Choose tests for requested behavior and established contracts. Tests must not
introduce new restrictions, guarantees, or threat scenarios merely because they
could be asserted. Follow the [agent policy](agents.md) when deciding whether a
behavior belongs to the contract; current implementation behavior alone is not
sufficient authority.
For established behavior involving **data integrity, destructive operations,
compatibility, security, concurrency, idempotency, or recovery**, favor durable
tests for realistic failures whose consequences warrant protection. Identify
the failure and the existing contract it would violate. These categories call
for attention, not an automatic test inventory or permission to strengthen the
application's guarantees. Account for protection already provided at other layers.
Do not add tests merely because a function, branch, or line exists. Do not add a test when the same meaningful risk is already adequately protected elsewhere.
@@ -77,7 +79,7 @@ Test through the narrowest stable boundary that expresses the behavior clearly.
This is often the package API, but it may instead be:
- a smaller pure function when dense application logic is most clearly isolated there;
- a smaller pure function when dense domain logic is most clearly isolated there;
- a package-level operation when several internal collaborators jointly produce the behavior; or
- a larger integration boundary when correctness emerges from interaction with a real dependency.
@@ -85,11 +87,7 @@ Do not force all behavior through oversized end-to-end tests. Do not test every
## Test behavior, not implementation
A test should protect a decision, contract, or invariant—not memorialize the current implementation.
Before adding or retaining a test, ask:
> What realistic defect would this test catch?
A test should protect a decision, contract, or invariant. A test must not memorialize the current implementation.
A test is suspect when its main purpose is to detect that someone:
@@ -102,8 +100,6 @@ A test is suspect when its main purpose is to detect that someone:
Refactoring should normally require no test edits unless the refactored structure is itself part of the contract.
A test can be factually correct and still have negative value. Accurately describing current behavior is not enough; the protected behavior must be important enough to justify the future friction.
## Expected effects of different changes
Use the following expectations when evaluating test failures and test maintenance:
@@ -121,10 +117,11 @@ A test failing is not the same as a test needing to be edited. Many tests may co
Configurable thresholds and defaults must not be duplicated throughout the test suite.
For example, do not encode an internal concurrency limit indirectly:
For this example, assume the selected contract rejects a request to start more
processes than a configured limit. Do not encode its production default indirectly:
```go
// Production policy:
// Production default:
const maxConcurrency = 4
// Brittle test:
@@ -142,18 +139,23 @@ require.NoError(t, runner.Start(limit))
require.ErrorIs(t, runner.Start(limit+1), ErrTooMuchConcurrency)
```
The test should prove:
For this selected contract, the test should prove:
- the configured limit is accepted; and
- one beyond the configured limit is rejected.
Rejection is not a general requirement for concurrency control. If the selected
contract queues excess work or limits active workers while accepting larger
batches, test that behavior instead. Do not add rejection or a configurable
limit solely to reproduce this example.
The production default should be tested exactly only when its literal value is itself a public, operational, safety, protocol, or compatibility requirement.
Apply the same rule to limits, timeouts, capacities, retry counts, and ranges: test relationships and behavior, not duplicated literals.
For concurrency limits, test both kinds of behavior when relevant:
For established concurrency limits, distinguish these behaviors where the contract requires them:
1. **Configuration enforcement:** invalid or excessive requested values are handled correctly.
1. **Configuration handling:** values are accepted or rejected according to the selected contract.
2. **Runtime enforcement:** observed peak concurrency never exceeds the configured limit.
Use a test-controlled limit and measure the behavior relative to that limit. Do not merely assert today's default value.
@@ -162,21 +164,21 @@ Use a test-controlled limit and measure the behavior relative to that limit. Do
Each behavior should have a clear test owner.
- Configuration tests own application YAML, discovery, precedence, and
application defaults.
- Configuration tests own application YAML, strict decoding, discovery,
precedence, and application defaults.
- CLI tests own argument mapping, streams, summaries, exit behavior, and
representative command workflows.
- HTTP tests own DTOs, strict decoding, limits, status mapping, and restricted
artifact policy.
- Formatter tests own prepared-run text and JSON presentation.
- Formatter tests own prepared-run and definition-inspection text and JSON
presentation.
- Architecture tests own dependency direction and removal invariants.
- Promptkit owns framework parsing, orchestration, validation, profiles, and
model-client behavior.
model-client behavior. Scriptorium's definition compatibility fixtures protect
its adapter plumbing without duplicating the upstream format contract.
Higher-level tests should not repeat every lower-level case. A single intentional policy change should not require unrelated edits across many test files.
Tests that are individually reasonable may still be collectively redundant. Evaluate the marginal value of each additional test in light of the protection already provided by the rest of the suite.
## Use test doubles deliberately
Choose the least elaborate test double that provides the required control or observation.
@@ -200,15 +202,16 @@ Do not use mocks merely to isolate every object or reproduce the implementation'
## Go-specific guidance
Use:
Select techniques according to the behavior and risk being tested. The following
are options, not a checklist that every package or application must satisfy:
- table-driven tests for meaningful behavioral categories and boundaries;
- `t.TempDir()` for real filesystem behavior;
- `httptest.Server` for realistic HTTP interactions;
- fuzz tests for parsers, normalization, path handling, and broad input spaces;
- fuzz tests when exploring broad input spaces, such as parsers or path handling, adds useful protection beyond representative cases;
- golden files only when the complete output is intentionally stable;
- integration tests where correctness depends on component interaction; and
- a small number of representative end-to-end tests.
- a small number of representative end-to-end tests when assembled workflows expose meaningful risks not adequately covered at narrower boundaries.
Avoid exact error-string assertions unless the wording is itself contractual. Prefer `errors.Is`, `errors.As`, typed errors, or structured error fields.
@@ -228,8 +231,6 @@ Security-sensitive HTTP containment and external mappings may warrant denser
coverage than straightforward process wiring. Uneven coverage is acceptable
when it reflects risk.
Increasing coverage is valuable only when the newly covered behavior protects a meaningful risk at an acceptable cost.
## Regression tests
A bug fix should normally include a regression test that fails before the fix and passes afterward.
@@ -258,44 +259,26 @@ Strong candidates include tests that:
Several brittle tests may encode one genuine requirement. Replace them with one durable behavior-level or invariant test rather than preserving all of them.
Deleting a low-value test can improve the quality of the suite by reducing noise, maintenance burden, and friction around legitimate change.
## Reviewing a proposed test
Use the following questions when the value, boundary, or durability of a proposed test is not self-evident. Significant test additions should be reviewable against them, but written answers are not required for every routine test.
1. What realistic defect would it catch?
2. How likely is that defect?
3. How consequential would it be?
4. Is the behavior already protected elsewhere?
5. At which layer should this behavior be owned?
6. Does the test assert a durable contract or an incidental implementation detail?
7. Could the implementation be refactored without changing the behavior and without editing this test?
8. What should cause this test to fail?
9. What legitimate changes should not cause this test to fail?
10. What ongoing maintenance, execution, and diagnostic cost will the test impose?
11. Is there a smaller or more direct test that protects the same risk?
Do not add the test when its expected lifetime cost exceeds its expected protective value.
When deciding not to test plausible behavior, record or be able to explain why the risk is low, already protected, obvious, reversible, or cheaper to detect elsewhere.
1. Which requested behavior or established contract does it protect, and what realistic defect would violate it?
2. How likely and consequential is that defect, and is it already protected elsewhere?
3. What is the narrowest stable boundary that can test it clearly?
4. Would a correct internal refactor or a change to an incidental default require test edits?
5. Does its confidence justify its maintenance, execution, and diagnostic cost, or would a smaller test suffice?
## Definition of sufficient
A test suite is sufficient when:
- important contracts and invariants are protected;
- meaningful boundaries and failure modes are exercised;
- realistic and consequential regressions are credibly protected against silent recurrence;
- behavior involving data integrity, destructive operations, compatibility, security, concurrency, idempotency, and recovery is credibly protected;
- important external boundaries have realistic integration coverage;
- representative complete workflows are tested;
- important requested behavior and established contracts are protected against realistic, consequential failures;
- boundaries and component interactions have coverage where they introduce meaningful risks not protected elsewhere;
- failures provide useful signal rather than redundant noise;
- legitimate internal changes usually do not require test edits; and
- additional tests would mostly repeat existing protection or preserve inconsequential implementation details.
Sufficiency is a risk judgment, not a coverage percentage or test count. Reassess it as the application, its users, and the consequences of failure evolve.
The governing rule is:
> Test heavily where failure is consequential, subtle, or difficult to detect after the fact. Test lightly where failure is obvious, reversible, and inexpensive—and retain no test whose lifetime cost exceeds the confidence it provides.
Sufficiency is a risk judgment, not a coverage percentage, test count, or required
mix of testing techniques. Reassess it as the application, its users, and the
consequences of failure evolve.

View File

@@ -1,377 +1,130 @@
# Release Procedure
## Release Model And Status
The [release policy](policy/release.md) owns versioning, compatibility,
publication completion, and immutability. This runbook supplies Scriptorium's
commands; release notes under `docs/releases/` are historical summaries.
Scriptorium publishes annotated semantic tags and tag-triggered Linux binary
releases. The hosted
[release workflow](../.woodpecker/release.yml) builds `amd64` and `arm64`
executables, publishes their SHA-256 checksums, and uses the matching file
under `docs/releases/` as the hosted release body.
## Prepare The Candidate
`v0.12.0` is the first published application-only release. For each later
release, select a new `vMAJOR.MINOR.PATCH` version according to the intended
compatibility change. A selected version remains an unreleased candidate until
its annotated tag is published, the hosted workflow succeeds, and every
published artifact is verified.
Run this procedure from the Scriptorium repository root. A release must not
depend on a Go workspace, module replacement, vendor tree, sibling checkout,
unpublished dependency, or unpushed source commit.
## Establish The Candidate
Start a POSIX shell, choose a semantic version that has not been published, and
export it as `RELEASE_VERSION`. For example, if `v0.12.1` is the intended next
version and remains unpublished, select:
Use a POSIX shell, Git with a configured tagger identity and access to
`origin`, and a Go toolchain compatible with `go.mod`. The native host must
support Go's race detector and have its required C compiler. If Git is
configured to sign tags, its signing tool and key must be available. Run from the
repository root. Provision the toolchain and module cache before offline
validation; when dependencies are not cached:
```sh
export RELEASE_VERSION=v0.12.1
GOWORK=off go mod download
```
Use the version appropriate to the actual compatibility change rather than
assuming that the example is the next release. Then run the following guard in
that same shell:
Choose an unused stable version under the release policy. Commit its note as
`docs/releases/<version>.md`, beginning with a title such as
`# Scriptorium v0.14.0` (an example, not an instruction to select that version).
Include a concise summary and relevant compatibility impact or upgrade action.
Update affected canonical documentation with the change, and retire completed
roadmap material when appropriate while preserving deferred work and links.
No extra release-specific review signoff is required.
Finish and commit the intended candidate on `main`, then publish that branch:
```sh
set -eu
: "${RELEASE_VERSION:?export an unpublished vMAJOR.MINOR.PATCH version}"
if ! printf '%s\n' "$RELEASE_VERSION" |
grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'
then
printf '%s\n' "invalid release version: $RELEASE_VERSION" >&2
exit 1
fi
RELEASE_COMMIT=$(git rev-parse --verify 'HEAD^{commit}')
export RELEASE_COMMIT
check_release_candidate() {
test "$(git branch --show-current)" = main
test -z "$(git status --porcelain)"
gowork_value=$(go env GOWORK)
case "$gowork_value" in
''|off) ;;
*)
printf '%s\n' "active Go workspace: $gowork_value" >&2
return 1
;;
esac
test -z "$(git ls-files go.work go.work.sum)"
test ! -e vendor
if grep -Eq '^[[:space:]]*replace([[:space:]]|\()' go.mod
then
printf '%s\n' 'go.mod contains a replacement' >&2
return 1
fi
git fetch origin main --tags
test "$RELEASE_COMMIT" = \
"$(git rev-parse --verify 'refs/remotes/origin/main^{commit}')"
if git show-ref --verify --quiet "refs/tags/$RELEASE_VERSION"
then
printf '%s\n' "local tag already exists: $RELEASE_VERSION" >&2
return 1
fi
if test -n "$(
git ls-remote --tags origin \
"refs/tags/$RELEASE_VERSION" \
"refs/tags/$RELEASE_VERSION^{}"
)"
then
printf '%s\n' "remote tag already exists: $RELEASE_VERSION" >&2
return 1
fi
}
check_release_candidate
git -c push.followTags=false push origin refs/heads/main:refs/heads/main
```
Do not continue unless this guard succeeds. It deliberately requires the
candidate to be the exact clean commit already published at `origin/main`.
The publication command inspects the configured push endpoint of `origin`,
not a potentially stale remote-tracking ref. If a separate push URL is
configured, it is authoritative for both checks and publication. Configure one
publication URL; multiple push endpoints cannot be validated as one remote.
## Verify Modules And Repository Boundaries
## Publish
Confirm the module path and declared Go version:
Invoke the single [publication command](../scripts/release.sh), substituting
the selected version:
```sh
test "$(
GOWORK=off go list -m -f '{{.Path}} {{.GoVersion}}'
)" = 'gitea.maximumdirect.net/eric/scriptorium 1.25.5'
sh scripts/release.sh v0.14.0
```
Require Promptkit `v0.9.0` as both the direct module-graph edge and the selected
module version:
The command validates a clean worktree and index on `main`, an unused local and
remote version, exact agreement with authoritative remote `main`, and a
checked-in note whose first-level title contains Scriptorium and the exact
version. Ignored local files do not make the candidate dirty.
The existing source-boundary guards reject committed workspaces, vendor trees,
and module replacements. No migration-specific exact Promptkit version audit
is required.
It then runs the complete suite once with the race detector and builds Linux
amd64 and arm64 binaries using `CGO_ENABLED=0`, `-trimpath`, and
`-ldflags '-s -w'`. The native test run uses the host platform and enables cgo.
Validation disables workspaces, uses read-only module mode, and disables module
and toolchain downloads. Missing dependencies must be provisioned separately;
fix the environment and rerun the command. No persistent validation cache is
maintained by the release tool.
The command rechecks candidate and publication state, creates an annotated tag
from the committed note, and pushes only that tag's exact object to its explicit
ref. It does not push branches, follow other tags, force updates, or wait for
CI. Temporary notes and validation builds are removed on exit. The successful
summary identifies the source commit and tag object and reports binary
delivery separately as asynchronous and unverified.
## Asynchronous Binary Delivery
The tag-triggered [hosted workflow](../.woodpecker/release.yml) builds and
publishes:
- `scriptorium-<version>-linux-amd64`;
- `scriptorium-<version>-linux-arm64`; and
- `SHA256SUMS`.
The matching checked-in note supplies the hosted release body. The workflow
owns image selection, packaging, checksum generation, and upload configuration.
Its upload step skips existing files and does not overwrite the release.
Source-publication success does not attest to workflow success or asset
availability.
Workflow inspection, downloads, checksum verification, and a compatible-host
smoke test are optional delivery diagnostics. They must not delay or redefine
source-release completion. No polling or consumer-installation check is part
of the publication command.
## Failure Recovery
Before tag creation, correct and commit a failed candidate, publish `main`,
and invoke the publication command again. A changed candidate must pass its
own validation.
After an unsuccessful push, the command queries the authoritative endpoint.
If it confirms the exact tag object and candidate, it reports source success.
Otherwise it exits unsuccessfully and preserves the local tag. It never retries
a push or deletes a tag automatically. If the endpoint is unavailable, the
publication status is unknown, not evidence that nothing was published.
For manual diagnosis, select the affected version and compare the local tag
object and peeled commit with the authoritative remote:
```sh
direct_promptkit=$(
GOWORK=off go mod graph |
awk '
$1 == "gitea.maximumdirect.net/eric/scriptorium" &&
$2 ~ /^gitea\.maximumdirect\.net\/eric\/promptkit@/ {
print $2
}
'
)
test "$direct_promptkit" = \
'gitea.maximumdirect.net/eric/promptkit@v0.9.0'
test "$(
GOWORK=off go list -m -f '{{.Path}}@{{.Version}}' \
gitea.maximumdirect.net/eric/promptkit
)" = 'gitea.maximumdirect.net/eric/promptkit@v0.9.0'
GOWORK=off go list -m all
release_version=v0.14.0
release_remote=$(git remote get-url --push origin)
git rev-parse "refs/tags/$release_version" "refs/tags/$release_version^{commit}"
git ls-remote "$release_remote" refs/heads/main \
"refs/tags/$release_version" "refs/tags/$release_version^{}"
```
Require tidy module metadata and recheck the repository exclusions:
Do not retry while the outcome remains unknown. If the tag exists remotely,
do not move, recreate, or delete it. If the tag was never published or used by
any release channel, an explicitly authorized local cleanup may delete only
that unpublished tag; then rerun the command to validate and publish the
candidate. The command intentionally rejects an existing local tag rather than
assuming that a previous process validated it.
```sh
GOWORK=off go mod tidy -diff
test -z "$(git ls-files go.work go.work.sum)"
test ! -e vendor
if grep -Eq '^[[:space:]]*replace([[:space:]]|\()' go.mod
then
printf '%s\n' 'go.mod contains a replacement' >&2
exit 1
fi
test -z "$(git status --porcelain)"
```
## Validate The Application
Run the complete application validation:
```sh
GOWORK=off go test ./...
GOWORK=off go test -race ./...
GOWORK=off go vet ./...
validation_build_dir=$(mktemp -d)
GOWORK=off go build \
-o "$validation_build_dir/scriptorium" \
./cmd/scriptorium
```
The ordinary test run includes the architecture guard that rejects a root Go
package, former framework package families, and imports of Promptkit internal
packages. Inspect the repository for generated binaries, credentials,
temporary output, sibling paths, and other files that do not belong in the
tracked release source.
Check every tracked Go file. This command must produce no output:
```sh
unformatted=$(
git ls-files '*.go' |
while IFS= read -r go_file
do
gofmt -l "$go_file"
done
)
test -z "$unformatted"
```
Run the maintained render and Promptkit v0.9 feature scripts, then smoke-test
both maintained configuration examples without a model call:
```sh
GOWORK=off ./examples/render-markdown-summary.sh
GOWORK=off ./examples/render-v0.9-features.sh
for config_file in examples/config.yml examples/config.full.yml
do
GOWORK=off go run ./cmd/scriptorium render \
--config "$config_file" \
--prompt generic.markdown_summary \
--input transcript=./examples/fixtures/transcript.md \
--input glossary=./examples/fixtures/glossary.yml \
--format json >/dev/null
done
```
Exercise usage output and offline rendering with the temporary native
executable:
```sh
usage_output="$validation_build_dir/usage.txt"
if "$validation_build_dir/scriptorium" >"$usage_output" 2>&1
then
printf '%s\n' 'expected an invocation without a command to fail' >&2
exit 1
fi
grep -F 'usage: scriptorium' "$usage_output"
"$validation_build_dir/scriptorium" render \
--config ./examples/config.yml \
--prompt generic.markdown_summary \
--input transcript=./examples/fixtures/transcript.md \
--input glossary=./examples/fixtures/glossary.yml \
--format text >/dev/null
```
Follow every maintained local, Promptkit-tagged, and other external Markdown
link. Confirm that all repository-relative link targets exist. Finish the
application checks with:
```sh
git diff --check
test -z "$(git status --porcelain)"
```
## Require Release Notes And Reproduce Packaging
The immutable release note must exist before tagging:
```sh
release_notes="docs/releases/$RELEASE_VERSION.md"
test -f "$release_notes"
test -s "$release_notes"
```
Validate every local and currently published link in the note. For links
pinned to the candidate Scriptorium tag, confirm that the corresponding
repository-relative path exists even though its tag URL is not live yet.
Reproduce the hosted build flags, targets, and filenames in a temporary
directory:
```sh
release_dist=$(mktemp -d)
release_package='gitea.maximumdirect.net/eric/scriptorium/cmd/scriptorium'
build_release_binary() {
target_os="$1"
target_arch="$2"
output="$release_dist/scriptorium-$RELEASE_VERSION-$target_os-$target_arch"
CGO_ENABLED=0 GOOS="$target_os" GOARCH="$target_arch" GOWORK=off \
go build -trimpath -ldflags '-s -w' \
-o "$output" "$release_package"
}
build_release_binary linux amd64
build_release_binary linux arm64
test -s "$release_dist/scriptorium-$RELEASE_VERSION-linux-amd64"
test -s "$release_dist/scriptorium-$RELEASE_VERSION-linux-arm64"
file "$release_dist/scriptorium-$RELEASE_VERSION-linux-amd64"
file "$release_dist/scriptorium-$RELEASE_VERSION-linux-arm64"
```
Require `file` to identify Linux executables for `x86-64` and `ARM aarch64`,
respectively. Inspect the
[hosted workflow](../.woodpecker/release.yml) and confirm that it uses the
same build flags and names, copies the selected release note to
`dist/RELEASE_NOTES.md`, publishes only `dist/scriptorium-*`, and keeps
checksum generation enabled.
## Create And Publish The Tag
Run the candidate guard again immediately before creating the tag:
```sh
check_release_candidate
test -f "$release_notes"
test -s "$release_notes"
```
Create an annotated tag explicitly bound to the validated commit, using the
version-specific release note as its message:
```sh
git tag --annotate "$RELEASE_VERSION" \
--file "$release_notes" \
"$RELEASE_COMMIT"
```
Inspect the tag and require it to resolve to the validated source:
```sh
test "$(git cat-file -t "refs/tags/$RELEASE_VERSION")" = tag
git show --no-patch --decorate "refs/tags/$RELEASE_VERSION"
test "$(
git rev-parse --verify "refs/tags/$RELEASE_VERSION^{commit}"
)" = "$RELEASE_COMMIT"
```
If inspection finds an error, delete only the unpublished local tag, correct
the candidate, and repeat the complete validation. Never move or recreate a
published tag.
Push only the selected tag ref:
```sh
git push origin \
"refs/tags/$RELEASE_VERSION:refs/tags/$RELEASE_VERSION"
```
## Observe And Verify Publication
Open the hosted workflow run for the selected tag. Require
`build-release-assets` to succeed before `publish-release`, then require the
publication step and hosted release to succeed. A queued, running, failed, or
partially published workflow is not a verified release.
Compare the local and remote annotated-tag objects and their source commits:
```sh
remote_tag=$(
git ls-remote --tags origin "refs/tags/$RELEASE_VERSION" |
awk 'NR == 1 { print $1 }'
)
remote_commit=$(
git ls-remote --tags origin "refs/tags/$RELEASE_VERSION^{}" |
awk 'NR == 1 { print $1 }'
)
test -n "$remote_tag"
test "$remote_tag" = \
"$(git rev-parse --verify "refs/tags/$RELEASE_VERSION")"
test "$remote_commit" = "$RELEASE_COMMIT"
```
Download the hosted binaries and checksum file into a temporary directory:
```sh
release_base="https://gitea.maximumdirect.net/eric/scriptorium/releases/download/$RELEASE_VERSION"
download_dir=$(mktemp -d)
(
cd "$download_dir"
for asset in \
"scriptorium-$RELEASE_VERSION-linux-amd64" \
"scriptorium-$RELEASE_VERSION-linux-arm64" \
SHA256SUMS
do
curl --fail --location --remote-name "$release_base/$asset"
done
test -s "scriptorium-$RELEASE_VERSION-linux-amd64"
test -s "scriptorium-$RELEASE_VERSION-linux-arm64"
test -s SHA256SUMS
sha256sum --check SHA256SUMS
test "$(wc -l < SHA256SUMS | tr -d ' ')" = 2
file "scriptorium-$RELEASE_VERSION-linux-amd64"
file "scriptorium-$RELEASE_VERSION-linux-arm64"
)
```
Require the same Linux architectures observed in the local packaging check and
confirm that the hosted release contains no unexpected asset. On a compatible
Linux host, make the matching downloaded binary executable and repeat the
usage-output and offline-render smoke checks against it.
Only after the tag, workflow, release body, binaries, architectures, and
checksums all pass verification is the candidate a verified published release.
## Handle Failures
Before tag publication, correct the release commit or note and restart the
complete procedure. After tag publication, never delete, move, overwrite, or
recreate the tag. A transient hosted failure may be retried only against the
same immutable tag and commit and only when doing so cannot overwrite or
silently retain partial assets. A source, packaging, note, or artifact defect
requires a new corrective semantic version from a new validated commit.
Record the selected version, validated commit, tag object, workflow result,
artifact names, checksum result, and smoke-check outcome in the release
checkpoint. Keep temporary builds and downloaded assets outside the repository,
and require a clean `main` synchronized with `origin/main` when verification
is complete.
For transient delivery failures, inspect what has already been uploaded before
retrying the same immutable tag. The upload step's skip behavior alone does not
prove checksum consistency. Add missing assets only if they match the tagged
source and any already-published manifest, without replacing existing contents.
Compare rebuilt bytes with published checksums when recovering a partial
upload; do not assume toolchain changes reproduce identical binaries. If safe
completion is impossible, or published source, notes, packaging, or artifact
contents need correction, leave the original release intact and publish a new
version from a corrected commit.

289
docs/roadmap/policy.md Normal file
View File

@@ -0,0 +1,289 @@
# Policy Template Adoption
## Status And Intent
Completed and validated on 2026-09-26. This roadmap retains the accepted
adoption requirements and implementation record. The implemented documents
under `docs/policy/` own current policy; this roadmap does not authorize release
publication.
### Implementation Record
- Added agent and release policies and merged the architecture, documentation,
and testing updates. All five policies have the specified provenance headers.
- Updated repository entry points and the contributor reading guide, preserving
the local ADR lifecycle and executable-interface documentation ownership.
- Added [the publication command](../../scripts/release.sh) and
[local-repository regression tests](../../scripts/release_test.go), and
replaced the old release procedure with the [short runbook](../release.md).
- Retained the hosted workflow after checking its targets, flags, release-note
source, checksums, and non-overwrite settings. Recovery guidance explicitly
requires consistency with already-published manifests before adding assets.
- The complete `go test ./...` and `go test -race ./...` suites pass, including
the release-tool tests and existing CLI tests. Both declared Linux builds,
the native build, `go vet ./...`, shell syntax, changed-document links, and
whitespace checks pass. Validation used the installed Go 1.25.5 toolchain
and a temporary build cache, without provider credentials or inference calls.
- Initial sandbox restrictions on local listeners and Git metadata were
lifted before final verification. No tests were weakened or skipped to work
around them. No real tag or release was published during implementation.
Use the templates in the sibling `../policy` repository at commit `45591a7` as
the adoption baseline. Customize them for Scriptorium rather than replacing
the existing policies verbatim. The sibling checkout is a review source, not a
new build, runtime, or contributor dependency.
The desired outcome is a complete, consistent policy set that preserves
Scriptorium's application boundaries, gives agents clear working rules, and
makes release preparation proportionate and repeatable.
## Policy Set And Provenance Headers
Every adopted policy document must place its base template version and local
last-updated date immediately below its title. For this adoption, use today's
date, **2026-09-26**, rather than the upstream template's last-updated date.
| Policy document | Adoption | Base Policy Version | Last Updated |
| --- | --- | --- | --- |
| `docs/policy/agents.md` | Add | 1.1 | 2026-09-26 |
| `docs/policy/architecture.md` | Merge with existing policy | 1.1 | 2026-09-26 |
| `docs/policy/documentation.md` | Merge with existing policy | 1.3 | 2026-09-26 |
| `docs/policy/release.md` | Add | 1.1 | 2026-09-26 |
| `docs/policy/testing.md` | Merge with existing policy | 1.1 | 2026-09-26 |
Use this header shape, substituting the applicable title and base version:
```markdown
# Architecture Policy
**Base Policy Version:** 1.1
**Last Updated:** 2026-09-26
```
The base version identifies the underlying upstream template, not a local
policy revision or Scriptorium release. Subsequent local revisions update the
date; the base version changes only when a newer template is adopted. Remove
upstream adoption instructions and template scaffolding from the finished
policies. All five documents must be substantive, repository-specific policy.
## Agent Guidance And Discoverability
Add `docs/policy/agents.md` as the canonical owner of coding-agent behavior.
Update `AGENTS.md` and the [development guide](../development.md) so agents read
it before recommending, planning, or implementing work, then follow the
task-specific reading guide. Preserve other applicable repository instructions.
The policy should favor idiomatic Go, standard-library facilities where
appropriate, simple ownership, and machinery proportionate to the actual
problem. It must discourage speculative abstractions, invented requirements,
unsupported guarantees, and treating incidental implementation details as
permanent policy.
Adopt the upstream local-commit default: complete and validate coherent units
of authorized work, review status and staged diffs, and commit those units with
plain English messages. Protect unrelated user changes and secrets, and do not
bypass hooks. An explicit request to leave work uncommitted overrides the
default. This rule does not authorize pushing, publishing releases, rewriting
history, or committing unrelated changes.
## Documentation Ownership
Merge the updated documentation template into the existing
[documentation policy](../policy/documentation.md), preserving its useful
Scriptorium-specific decisions:
- One canonical owner per contract, with short summaries and links elsewhere.
- Executable-interface consumer guidance, not a fictitious public Go API.
- Tested minimal orientation examples and complete copyable assets in
`examples/`.
- No irrelevant placeholder documents or template-only TODO sections.
- The existing ADR lifecycle, including proposed, accepted, rejected, and
superseded decisions; immutable accepted decision content; and reciprocal
supersession links.
Add ownership for agent policy and release policy. The release policy owns
versioning, compatibility, integrity, gate selection, publication controls,
immutability, and the definition of release completion. The
[release runbook](../release.md) owns concrete repository commands and recovery
procedures. Version-specific notes remain immutable release history.
Architecture policy owns deliberate constraints and normative boundaries;
the [internal overview](../internal/overview.md) owns implemented component
structure. Roadmaps own planned work and implementation status. Accepted ADRs
may precede implementation but must not imply that planned behavior exists.
Update reading-guide links and ownership descriptions together so old and new
documents do not prescribe competing workflows.
## Architecture Refinements
Retain the existing adapter-oriented architecture and supported Promptkit root
package dependency. This policy refresh does not require new wrappers,
interfaces, packages, or another architecture migration.
Explicitly connect the policy to
[ADR 0004](../adr/0004-definition-boundary.md): support valid prompt and profile
definitions for the selected Promptkit version, while considering library-only
features individually. Requests remain independent from the caller's
perspective; shared process-scoped engines, backend capacity accounting,
in-flight coordination, and lifecycle state remain permitted and, when needed
to enforce limits, required. Do not describe the application as internally
stateless or introduce conversation and durable workflow responsibilities.
Preserve strict application decoding, application-owned HTTP artifact limits,
and the separation between tagged upstream dependencies and Scriptorium's
executable adapters. Retain the existing guards against local replacements,
committed workspaces, vendored Promptkit implementation, and unsupported
imports.
State architectural constraints deliberately rather than freezing every
current implementation choice. Explain the actual artifact threat boundary:
lexical containment does not prevent operating-system symlink following. Do
not invent a stronger filesystem-security guarantee or require defenses
against concurrent privileged filesystem modification as part of this update.
Keep operational mitigations and interface outcomes in their canonical owners.
## Risk-Based Testing
Merge the updated testing template into the
[testing policy](../policy/testing.md). Test sufficiency should follow the
established contract, realistic failure risk, and protections already supplied
by other layers, rather than a mandatory matrix of possible cases.
Preserve existing coverage responsibilities for configuration precedence and
strict decoding; CLI streams, summaries, exits, and representative workflows;
HTTP DTOs, limits, statuses, and artifact restrictions; deterministic prepared
run and definition-inspection presentation; and architecture boundaries.
Promptkit remains responsible for framework semantics, while Scriptorium's
compatibility fixtures protect its adapter plumbing.
Use real collaborators where practical, deterministic offline tests, and no
required provider credentials or paid API calls. Assert meaningful behavior
rather than helper structure or incidental wording. Concurrency tests must
follow the selected admission and queueing contract; an upstream example of
rejection is not a mandate to reject requests that should queue. Fuzzing,
matrices, and other techniques are options justified by risk, not universal
requirements. This adoption is not a blanket test rewrite or an excuse to
remove valuable compatibility coverage.
## Release Policy And Publication Workflow
The largest functional change in this adoption is release tooling and guidance,
not application behavior. Add `docs/policy/release.md` and shorten the existing
runbook around one repository-owned publication command, with its concrete
invocation documented there. Keep CI implementation details in the workflow.
### Versioning And Release Identity
Retain `origin` as the authoritative remote, `main` as the release branch,
annotated stable tags in `vMAJOR.MINOR.PATCH` form, and matching
`docs/releases/<version>.md` notes and titles. Reject invalid versions,
including leading-zero components, and tags already present locally or on the
authoritative remote. Do not add a prerelease channel in this adoption.
Before 1.0, minor releases may change documented contracts when release notes
explain the impact and required action; patch releases preserve the minor
line's compatibility. From 1.0 onward, use normal semantic-versioning
compatibility rules. Preserve cross-project tagged-release boundaries rather
than reviving migration-specific dependency audits.
### A Single Validated Candidate
Publish the intended `main` commit before invoking the release command. The
command must establish a clean candidate on `main` that exactly matches
authoritative `origin/main`, validate its version and notes, run the required
checks, and bind publication to that exact commit. A changed candidate
invalidates validation. Check publication-sensitive state again where needed
without rerunning expensive tests on an unchanged candidate.
Use one complete deterministic offline test-suite invocation for the final
candidate. The recommended invocation is `GOWORK=off go test -race ./...`:
it checks ordinary assertions and race behavior together, with race coverage
justified by the application's shared HTTP and backend operational state.
Keep dependency provisioning separate from offline test execution.
Build both existing Linux targets, amd64 and arm64, using the release settings
(`CGO_ENABLED=0`, `-trimpath`, and `-ldflags "-s -w"`). Cross-building does not
imply requiring native test hardware for both architectures. Existing
architecture tests continue to enforce dependency boundaries.
Remove unconditional duplicate test runs, migration-era exact Promptkit
version audits, repeated example checks, broad documentation-link scans, and
other release gates without a distinct release-risk justification. Formatting,
vetting, affected examples, and documentation validation remain appropriate
change-development checks; they need not all be repeated as publication gates.
Do not introduce a persistent validation cache or a new platform matrix.
Ignored local build products are not inherently release blockers and do not
justify broad cleanup. Normal change review still protects against tracked
junk and secrets.
### Source Publication And Asynchronous Delivery
Create the annotated tag for the validated commit using its release notes and
push only that explicit tag ref. Do not force-push or publish all local tags.
A successful tag push completes source publication. Report binary delivery
separately as asynchronous and unverified unless actually checked.
Preserve the hosted workflow's Linux amd64/arm64 binaries, release-note body,
and `SHA256SUMS` delivery. Align the workflow only where needed to support the
policy. CI polling, artifact downloads, consumer installation checks, module
availability checks, and remote readback after an unequivocally successful
push are optional diagnostics, not completion gates. Failed or ambiguous
pushes require authoritative remote-state resolution before reporting success
or deciding whether to retry.
Published tags, source release notes, binaries, and checksums remain immutable.
Transient CI, authentication, or network failures do not automatically require
a new version: missing assets may be delivered later for the same tag without
overwriting existing assets or creating inconsistent checksum manifests.
Corrections that change published contents require a corrective commit and a
new version. Recovery guidance must distinguish these situations.
Version selection, release-note preparation, and retirement of completed
roadmaps are normal preparation work, not additional approval or signoff gates.
Preserve deferred work and useful references when retiring roadmap material.
## Adoption Sequence
The completed implementation covers these three adoption units:
1. **Working guidance:** add agent policy; merge documentation and testing
policies; add their provenance headers; update `AGENTS.md` and contributor
navigation. Preserve local ADR and application-consumer conventions.
2. **Architectural clarity:** merge architecture-template refinements, add its
header, and connect the policy to ADR 0004 and the actual threat scope
without changing runtime behavior or historical decisions.
3. **Release simplification:** add release policy and its header, implement and
validate the publication command, simplify the runbook, and align workflow
and documentation ownership references as necessary. Land the new release
procedure with its working tooling rather than documenting a command that
does not yet exist.
This sequence is an adoption outline, not an application-feature implementation
plan. Current policy is maintained in the adopted policy documents.
## Validation And Completion
The adoption is complete when:
- All five policies have the specified base-version and last-updated headers,
substantive local content, and no upstream adoption scaffolding.
- Repository entry points lead to the appropriate policy, links resolve, and
ownership is consistent across policy, runbook, ADRs, and internal docs.
- Existing product boundaries, compatibility protections, and security claims
are preserved without speculative guarantees or unrelated refactoring.
- The release command enforces the exact validated candidate and explicit tag
publication, and the runbook clearly distinguishes source completion from
optional delivery verification and recovery.
- Release-tool tests use temporary local Git repositories rather than real
publication. Cover meaningful failures such as invalid or reused versions,
dirty or staged changes, wrong branch or remote mismatch, missing or
mismatched notes, failed validation, a changed candidate, and ambiguous push
outcomes, as well as successful publication of a clean published candidate.
Keep this coverage proportionate; do not require external CI or providers.
- Published-content immutability and safe missing-artifact recovery remain
intact. No actual release is created merely to validate this adoption.
Application features, Promptkit upgrades, rewriting accepted ADR decisions,
editing historical published release notes, and release publication are outside
this roadmap's implementation scope.

122
scripts/release.sh Normal file
View File

@@ -0,0 +1,122 @@
#!/bin/sh
# Publish one validated source release; binary delivery is asynchronous.
set -eu
die() {
printf '%s\n' "release: $*" >&2
exit 1
}
[ "$#" -eq 1 ] || die 'usage: sh scripts/release.sh vMAJOR.MINOR.PATCH'
version=$1
case "$version" in
*'
'*) die 'invalid stable release version' ;;
esac
printf '%s\n' "$version" |
grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' ||
die 'invalid stable release version'
cd "$(git rev-parse --show-toplevel)"
candidate=$(git rev-parse --verify 'HEAD^{commit}')
tag_ref="refs/tags/$version"
notes="docs/releases/$version.md"
# Inspect and publish to the same endpoint, including when origin has a
# separate push URL. A raw URL avoids configured mirror/refspec behavior.
remote=$(git remote get-url --push --all origin)
case "$remote" in
''|*'
'*) die 'origin must have exactly one publication URL' ;;
esac
check_candidate() {
[ "$(git branch --show-current)" = main ] || die 'candidate must be on main'
[ -z "$(git status --porcelain --untracked-files=all)" ] ||
die 'worktree and index must be clean'
[ "$(git rev-parse --verify 'HEAD^{commit}')" = "$candidate" ] ||
die 'candidate changed during validation; validate the new candidate'
[ "$(git remote get-url --push --all origin)" = "$remote" ] ||
die 'origin publication URL changed during validation'
}
read_remote() {
remote_state=$(git ls-remote --refs "$remote" refs/heads/main "$tag_ref") ||
return 1
remote_main=$(printf '%s\n' "$remote_state" |
awk '$2 == "refs/heads/main" { print $1 }')
remote_tag=$(printf '%s\n' "$remote_state" |
awk -v ref="$tag_ref" '$2 == ref { print $1 }')
}
check_publication() {
check_candidate
if git show-ref --verify --quiet "$tag_ref"; then
die 'version already exists locally; see docs/release.md for recovery'
fi
read_remote || die 'cannot establish authoritative publication state'
[ "$remote_main" = "$candidate" ] ||
die 'candidate must exactly match main on origin; publish main first'
[ -z "$remote_tag" ] || die 'version already exists on origin'
}
check_publication
[ -z "$(git ls-tree -r --name-only "$candidate" -- go.work go.work.sum vendor)" ] ||
die 'candidate must not contain a workspace or vendor tree'
if grep -Eq '^[[:space:]]*replace([[:space:]]|\()' go.mod; then
die 'candidate must use published dependencies without go.mod replacements'
fi
git cat-file -e "$candidate:$notes" 2>/dev/null ||
die 'matching release note is missing from the candidate'
release_tmp=$(mktemp -d "${TMPDIR:-/tmp}/scriptorium-release.XXXXXX")
trap 'rm -rf -- "$release_tmp"' EXIT
trap 'exit 1' HUP INT TERM
git show "$candidate:$notes" >"$release_tmp/notes.md"
awk -v version="$version" '
NR == 1 {
for (i = 2; i <= NF; i++) {
if ($i == "Scriptorium") product = 1
if ($i == version) matched = 1
}
valid = ($1 == "#" && product && matched)
}
END { exit !valid }
' "$release_tmp/notes.md" || die 'release note title must contain Scriptorium and the exact version'
# Dependencies and the toolchain are provisioned before invocation. Do not
# discover a workspace, use vendor mode, modify module metadata, or download
# modules/toolchains during candidate validation.
export GOWORK=off GOFLAGS=-mod=readonly GOPROXY=off GONOPROXY=none GOSUMDB=off GOTOOLCHAIN=local
host_os=$(go env GOHOSTOS)
host_arch=$(go env GOHOSTARCH)
GOOS="$host_os" GOARCH="$host_arch" CGO_ENABLED=1 go test -race ./...
for arch in amd64 arm64; do
GOOS=linux GOARCH="$arch" CGO_ENABLED=0 go build -trimpath -ldflags '-s -w' \
-o "$release_tmp/scriptorium-$version-linux-$arch" ./cmd/scriptorium
done
check_publication
git tag --annotate --cleanup=verbatim \
--file "$release_tmp/notes.md" "$version" "$candidate"
tag_object=$(git rev-parse --verify "$tag_ref")
[ "$(git cat-file -t "$tag_object")" = tag ] || die 'expected an annotated tag'
[ "$(git rev-parse --verify "$tag_object^{commit}")" = "$candidate" ] ||
die 'tag does not identify the validated candidate'
check_candidate
# Freeze the source object and name only the intended destination ref. Disable
# automatic following of other annotated tags even if enabled in user config.
if git -c push.followTags=false push "$remote" "$tag_object:$tag_ref"; then
: # Successful pushes do not need another network read-back gate.
else
printf '%s\n' 'release: push failed; resolving authoritative remote state' >&2
read_remote || die 'publication status unknown; preserve the local tag and inspect origin before retrying'
if [ "$remote_tag" != "$tag_object" ] || [ "$remote_main" != "$candidate" ]; then
die 'publication not confirmed; preserve the local tag and follow docs/release.md recovery'
fi
printf '%s\n' 'release: origin confirms the intended tag despite the push error'
fi
printf 'Source release %s published: commit %s, tag %s\n' "$version" "$candidate" "$tag_object"
printf '%s\n' 'Binary delivery: asynchronous, not verified.'

357
scripts/release_test.go Normal file
View File

@@ -0,0 +1,357 @@
package scripts_test
import (
"context"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
)
const version = "v0.14.0"
// Git operations use real temporary repositories. Go is the expensive boundary:
// record validation commands rather than recursively running this test suite.
const fakeGo = `#!/bin/sh
set -eu
if [ "$1" = env ]; then
case "$2" in
GOHOSTOS) printf 'linux\n' ;;
GOHOSTARCH) printf 'amd64\n' ;;
*) exit 1 ;;
esac
exit 0
fi
[ "$GONOPROXY" = none ]
printf '%s|%s|%s|%s|%s|%s|%s|%s|%s\n' "$*" "$GOWORK" "$GOFLAGS" "$GOPROXY" "$GOSUMDB" "$GOTOOLCHAIN" "$GOOS" "$GOARCH" "$CGO_ENABLED" >> "$RELEASE_TEST_LOG"
if [ "$1" = test ] && [ -n "${RELEASE_TEST_MUTATION:-}" ]; then
sh "$RELEASE_TEST_MUTATION"
fi
case "${RELEASE_TEST_FAIL:-}" in
test) [ "$1" != test ] || exit 1 ;;
arm64) [ "$GOARCH" != arm64 ] || exit 1 ;;
esac
`
// Simulate the transport losing the reply after a real local push, rejecting
// the push, or becoming unavailable while recovery queries the remote.
const gitTransport = `#!/bin/sh
set -eu
if [ "$1" = ls-remote ] && [ -f "$RELEASE_TEST_PUSH_MARKER" ]; then
if [ "$RELEASE_TEST_PUSH" = unknown ] || [ "$RELEASE_TEST_PUSH" = confirmed ]; then
exit 1
fi
fi
if [ "$1" = -c ] && [ "$3" = push ]; then
if [ "$RELEASE_TEST_PUSH" = rejected ]; then exit 1; fi
if [ "$RELEASE_TEST_PUSH" = conflict ]; then
"$RELEASE_TEST_GIT" push "$4" HEAD:refs/tags/v0.14.0
else
"$RELEASE_TEST_GIT" "$@"
fi
touch "$RELEASE_TEST_PUSH_MARKER"
if [ "$RELEASE_TEST_PUSH" = confirmed ]; then exit 0; fi
exit 1
fi
exec "$RELEASE_TEST_GIT" "$@"
`
type fixture struct {
t *testing.T
root string
repo string
remote string
bin string
gitPath string
log string
env []string
candidate string
}
func newFixture(t *testing.T) *fixture {
t.Helper()
if runtime.GOOS == "windows" {
t.Skip("release command requires a POSIX shell")
}
gitPath, err := exec.LookPath("git")
if err != nil {
t.Fatal("release tests require git:", err)
}
root := t.TempDir()
f := &fixture{
t: t, root: root, repo: filepath.Join(root, "work"),
remote: filepath.Join(root, "origin.git"), bin: filepath.Join(root, "bin"),
gitPath: gitPath, log: filepath.Join(root, "validation.log"),
}
for _, env := range os.Environ() {
key, _, _ := strings.Cut(env, "=")
if !strings.HasPrefix(key, "GIT_") && !strings.HasPrefix(key, "RELEASE_TEST_") && key != "PATH" {
f.env = append(f.env, env)
}
}
f.env = append(f.env,
"PATH="+f.bin+string(os.PathListSeparator)+os.Getenv("PATH"),
"GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL="+os.DevNull,
"GIT_ALLOW_PROTOCOL=file", "GIT_TERMINAL_PROMPT=0",
"RELEASE_TEST_LOG="+f.log, "RELEASE_TEST_GIT="+gitPath,
"RELEASE_TEST_PUSH_MARKER="+filepath.Join(root, "pushed"),
)
f.run(root, gitPath, "init", "--bare", "--initial-branch=main", f.remote)
f.run(root, gitPath, "init", "--initial-branch=main", f.repo)
f.git("config", "user.name", "Release Test")
f.git("config", "user.email", "release@example.invalid")
f.git("remote", "add", "origin", f.remote)
f.write(filepath.Join(f.bin, "go"), fakeGo, 0o755)
script, err := os.ReadFile("release.sh")
if err != nil {
t.Fatal(err)
}
f.write(filepath.Join(f.repo, "scripts", "release.sh"), string(script), 0o644)
f.write(filepath.Join(f.repo, "docs", "releases", version+".md"), "# Scriptorium "+version+"\n\nTest release.\n", 0o644)
f.write(filepath.Join(f.repo, ".gitignore"), "ignored-output\n", 0o644)
f.write(filepath.Join(f.repo, "go.mod"), "module release.test/example\n\ngo 1.25.5\n", 0o644)
f.commit()
f.candidate = strings.TrimSpace(f.git("rev-parse", "HEAD"))
return f
}
func (f *fixture) write(path, content string, mode os.FileMode) {
f.t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
f.t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), mode); err != nil {
f.t.Fatal(err)
}
}
func (f *fixture) run(dir, name string, args ...string) string {
f.t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, name, args...)
cmd.Dir, cmd.Env = dir, f.env
out, err := cmd.CombinedOutput()
if err != nil {
f.t.Fatalf("%s %v: %v\n%s", name, args, err, out)
}
return string(out)
}
func (f *fixture) git(args ...string) string {
f.t.Helper()
return f.run(f.repo, f.gitPath, args...)
}
func (f *fixture) commit() {
f.t.Helper()
f.git("add", ".")
f.git("commit", "-m", "Prepare test candidate")
f.git("push", "origin", "refs/heads/main:refs/heads/main")
}
func (f *fixture) publish(wantSuccess bool, args ...string) string {
f.t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "sh", append([]string{"scripts/release.sh"}, args...)...)
cmd.Dir, cmd.Env = f.repo, f.env
out, err := cmd.CombinedOutput()
if (err == nil) != wantSuccess {
f.t.Fatalf("release success=%v, want %v: %v\n%s", err == nil, wantSuccess, err, out)
}
return string(out)
}
func (f *fixture) noPublishedTag() {
f.t.Helper()
if out := f.git("ls-remote", "--tags", f.remote, "refs/tags/"+version); strings.TrimSpace(out) != "" {
f.t.Fatalf("unexpected published tag: %s", out)
}
}
func TestReleasePublishesOnlyValidatedTag(t *testing.T) {
f := newFixture(t)
f.git("tag", "-a", "unrelated", "-m", "Do not publish this")
f.git("config", "push.followTags", "true")
f.git("config", "remote.origin.mirror", "true")
f.write(filepath.Join(f.repo, "ignored-output"), "local build", 0o644)
out := f.publish(true, version)
if !strings.Contains(out, "Source release "+version+" published") || !strings.Contains(out, "asynchronous, not verified") {
t.Fatalf("missing source/delivery status: %s", out)
}
tag := strings.TrimSpace(f.git("rev-parse", "refs/tags/"+version))
if got := strings.TrimSpace(f.git("cat-file", "-t", tag)); got != "tag" {
t.Fatalf("object type = %s", got)
}
if got := strings.TrimSpace(f.git("rev-parse", tag+"^{commit}")); got != f.candidate {
t.Fatalf("tag commit = %s, want %s", got, f.candidate)
}
note, err := os.ReadFile(filepath.Join(f.repo, "docs", "releases", version+".md"))
if err != nil {
t.Fatal(err)
}
if object := f.git("cat-file", "-p", tag); !strings.HasSuffix(object, "\n\n"+string(note)) {
t.Fatalf("tag message differs from committed note: %s", object)
}
refs := f.git("ls-remote", "--refs", f.remote)
if strings.Count(strings.TrimSpace(refs), "\n") != 1 || !strings.Contains(refs, tag+"\trefs/tags/"+version) || !strings.Contains(refs, f.candidate+"\trefs/heads/main") {
t.Fatalf("unexpected published refs: %s", refs)
}
log, err := os.ReadFile(f.log)
if err != nil {
t.Fatal(err)
}
lines := strings.Split(strings.TrimSpace(string(log)), "\n")
if len(lines) != 3 || lines[0] != "test -race ./...|off|-mod=readonly|off|off|local|linux|amd64|1" {
t.Fatalf("unexpected validation calls: %s", log)
}
for i, arch := range []string{"amd64", "arm64"} {
if !strings.HasPrefix(lines[i+1], "build -trimpath -ldflags -s -w -o ") ||
!strings.Contains(lines[i+1], "scriptorium-"+version+"-linux-"+arch+" ./cmd/scriptorium|") ||
!strings.HasSuffix(lines[i+1], "|off|-mod=readonly|off|off|local|linux|"+arch+"|0") {
t.Fatalf("unexpected build: %s", lines[i+1])
}
}
if got := f.git("status", "--porcelain"); got != "" {
t.Fatalf("release dirtied worktree: %s", got)
}
}
func TestReleaseRejectsInvalidCandidates(t *testing.T) {
tests := []struct {
name string
args []string
edit func(*fixture)
}{
{name: "no version"},
{name: "leading zero", args: []string{"v00.14.0"}},
{name: "prerelease", args: []string{"v0.14.0-rc.1"}},
{name: "multiline version", args: []string{"v0.14.0\ninvalid"}},
{name: "local tag", edit: func(f *fixture) { f.git("tag", version) }},
{name: "remote tag", edit: func(f *fixture) {
f.run(f.root, f.gitPath, "--git-dir="+f.remote, "update-ref", "refs/tags/"+version, f.candidate)
}},
{name: "dirty", edit: func(f *fixture) { f.write(filepath.Join(f.repo, ".gitignore"), "changed\n", 0o644) }},
{name: "untracked", edit: func(f *fixture) { f.write(filepath.Join(f.repo, "untracked"), "new\n", 0o644) }},
{name: "staged", edit: func(f *fixture) {
f.write(filepath.Join(f.repo, ".gitignore"), "changed\n", 0o644)
f.git("add", ".gitignore")
}},
{name: "wrong branch", edit: func(f *fixture) { f.git("switch", "-c", "topic") }},
{name: "unpublished main", edit: func(f *fixture) { f.git("commit", "--allow-empty", "-m", "Unpublished") }},
{name: "missing note", edit: func(f *fixture) { f.git("rm", "docs/releases/"+version+".md"); f.commit() }},
{name: "mismatched title", edit: func(f *fixture) {
f.write(filepath.Join(f.repo, "docs/releases/"+version+".md"), "# Scriptorium v0.14.1\n", 0o644)
f.commit()
}},
{name: "empty note", edit: func(f *fixture) {
f.write(filepath.Join(f.repo, "docs/releases/"+version+".md"), "", 0o644)
f.commit()
}},
{name: "committed workspace", edit: func(f *fixture) {
f.write(filepath.Join(f.repo, "go.work"), "go 1.25.5\nuse .\n", 0o644)
f.commit()
}},
{name: "committed vendor tree", edit: func(f *fixture) {
f.write(filepath.Join(f.repo, "vendor", "modules.txt"), "vendored dependency\n", 0o644)
f.commit()
}},
{name: "module replacement", edit: func(f *fixture) {
f.write(filepath.Join(f.repo, "go.mod"), "module release.test/example\n\ngo 1.25.5\nreplace example.org/dependency => ../dependency\n", 0o644)
f.commit()
}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := newFixture(t)
if tt.edit != nil {
tt.edit(f)
}
args := tt.args
if args == nil && tt.name != "no version" {
args = []string{version}
}
f.publish(false, args...)
if _, err := os.Stat(f.log); !os.IsNotExist(err) {
t.Fatalf("invalid candidate reached expensive validation: %v", err)
}
if tt.name != "remote tag" {
f.noPublishedTag()
}
})
}
}
func TestReleaseStopsAfterFailedValidation(t *testing.T) {
for _, step := range []string{"test", "arm64"} {
t.Run(step, func(t *testing.T) {
f := newFixture(t)
f.env = append(f.env, "RELEASE_TEST_FAIL="+step)
f.publish(false, version)
f.noPublishedTag()
if tags := f.git("tag", "--list", version); tags != "" {
t.Fatalf("failed validation created tag: %s", tags)
}
})
}
}
func TestReleaseRechecksCandidateAndRemote(t *testing.T) {
for name, mutation := range map[string]string{
"candidate changed": "git commit --allow-empty -m changed\ngit push origin main\n",
"worktree changed": "touch unexpected\n",
"remote main changed": "git --git-dir=\"$RELEASE_TEST_REMOTE\" update-ref -d refs/heads/main\n",
"remote tag appeared": "git push origin HEAD:refs/tags/v0.14.0\n",
} {
t.Run(name, func(t *testing.T) {
f := newFixture(t)
path := filepath.Join(f.root, "mutate.sh")
f.write(path, "set -eu\n"+mutation, 0o644)
f.env = append(f.env, "RELEASE_TEST_MUTATION="+path, "RELEASE_TEST_REMOTE="+f.remote)
f.publish(false, version)
if tags := f.git("tag", "--list", version); tags != "" {
t.Fatalf("changed candidate created tag: %s", tags)
}
if name != "remote tag appeared" {
f.noPublishedTag()
}
})
}
}
func TestReleaseHandlesPushOutcomes(t *testing.T) {
for _, mode := range []string{"confirmed", "lost reply", "rejected", "unknown", "conflict"} {
t.Run(mode, func(t *testing.T) {
f := newFixture(t)
f.write(filepath.Join(f.bin, "git"), gitTransport, 0o755)
f.env = append(f.env, "RELEASE_TEST_PUSH="+mode)
out := f.publish(mode == "confirmed" || mode == "lost reply", version)
if mode == "unknown" && !strings.Contains(out, "publication status unknown") {
t.Fatalf("did not report uncertainty: %s", out)
}
if tag := f.git("tag", "--list", version); strings.TrimSpace(tag) != version {
t.Fatalf("local tag was not preserved: %s", tag)
}
if mode == "rejected" {
f.noPublishedTag()
}
if mode == "confirmed" || mode == "lost reply" || mode == "unknown" {
tag := strings.TrimSpace(f.git("rev-parse", "refs/tags/"+version))
if remote := f.git("ls-remote", "--refs", f.remote, "refs/tags/"+version); !strings.HasPrefix(remote, tag+"\t") {
t.Fatalf("published tag mismatch: %s", remote)
}
}
})
}
}
func TestReleaseUsesOriginPushEndpoint(t *testing.T) {
f := newFixture(t)
f.git("remote", "set-url", "--push", "origin", f.remote)
f.git("remote", "set-url", "origin", filepath.Join(f.root, "unavailable-fetch.git"))
f.publish(true, version)
}