Merge pull request 'MIL-003: local project, framework submodule, hooks, templates and README' (#26) from mil-003-scaffold-and-release into main
TirSystem/github-action: Sync GitHub mirror metadata / sync-metadata (push) Successful in 4s

Reviewed-on: #26
This commit was merged in pull request #26.
This commit is contained in:
2026-10-05 09:55:13 +02:00
19 changed files with 1185 additions and 68 deletions
+264 -35
View File
@@ -1,35 +1,182 @@
# RepoFoundry
RepoFoundry (`src/create-project.sh`) sets up a new project: a Gitea
repository, optionally an empty GitHub repository with a push mirror from
Gitea to GitHub, and (in a later phase) a local project with the
SQA-QC-Framework.
RepoFoundry (`src/create-project.sh`) sets up a new project in one run:
> **Status: work in progress.** The script can validate its configuration,
> check both hosts and create the repositories and the mirror. Creating the
> local project, and the full installation guide, come in a later phase
> (MIL-003). This file so far documents what is needed to run the host steps
> safely.
- a **Gitea** repository (the source of truth),
- optionally an empty **GitHub** repository that receives everything through a
**push mirror from Gitea to GitHub**,
- and a **local project** with credential-free remotes and the
[SQA-QC-Framework](https://git.tirsystem.com/TirSystem/SQA-QC-Framework)
added as a git submodule, with its skills, git hooks (and optionally the plan
gate) and templates installed.
## Quick start
It is a Bash script. It asks for the repository name, description, visibility
and owner (a user or an organization, separately on each host), shows a plan,
and only creates anything after you pass `--apply` and answer yes.
> **Status.** The script is tested with stubbed host APIs and real git against
> local repositories (see [Development](#development)). A first run against
> real GitHub and Gitea repositories is still to be recorded.
## Contents
1. [Installation](#installation)
2. [Configuration](#configuration)
3. [Usage](#usage)
4. [SSH access to Gitea](#ssh-access-to-gitea)
5. [Token permissions](#token-permissions)
6. [Security decisions](#security-decisions)
7. [Error handling and recovery](#error-handling-and-recovery)
8. [Known limitations](#known-limitations)
9. [Code layout](#code-layout)
10. [Development](#development)
11. [Stakeholders](#stakeholders)
12. [License](#license)
## Installation
Requirements:
| Tool | Needed for |
| --- | --- |
| bash 4.4 or later | the script (macOS ships 3.2: install a newer bash first) |
| `git` | the local project and the framework submodule |
| `curl` | the GitHub and Gitea APIs |
| `mktemp` and the usual base tools | temporary files and small helpers |
| `ssh` (optional) | the SSH check; without it the framework steps are skipped |
| `jq` (optional) | JSON parsing; without it a small built-in reader is used |
```bash
git clone https://git.tirsystem.com/TirSystem-BashScript/RepoFoundry.git
cd RepoFoundry
src/create-project.sh --help
```
Nothing has to be installed system-wide: the script runs from the checkout and
loads its own files from `src/lib/`.
## Configuration
The script reads two plain files from the project root. They are **parsed,
never executed** (`source` is not used): only `KEY=VALUE` lines with known keys
are accepted, and anything else stops the run with a message that names the key
and the line, never the value.
```bash
cp config.env.example config.env # service addresses, not secret
cp .env.example .env # credentials: keep private
chmod 600 .env # Linux and macOS
src/create-project.sh # dry run: reads from the hosts, creates nothing
src/create-project.sh --apply # creates the repositories and the mirror
```
Without `--apply` the script only reads from GitHub and Gitea (it checks the
tokens, the owners, the name, the license and SSH) and prints a plan. With
`--apply` it prints the plan again and asks a final question before it creates
anything. Nothing is ever deleted by the script.
### `config.env` (service addresses)
Choosing GitHub also applies the AGPL-3.0 license to the Gitea repository, so
that repository is not empty. Without GitHub the Gitea repository is created
empty and has no license.
| Key | Meaning | Default |
| --- | --- | --- |
| `GITHUB_API_URL` | GitHub REST API base URL | `https://api.github.com` |
| `GITHUB_WEB_URL` | GitHub web base URL (links and the mirror address) | `https://github.com` |
| `GITEA_URL` | Gitea base URL (required) | |
| `GITEA_API_URL` | Gitea REST API base URL | `GITEA_URL` + `/api/v1` |
| `GITEA_SSH_PORT` | SSH port of the Gitea server | `10022` |
| `MIRROR_INTERVAL` | how often Gitea pushes to GitHub, e.g. `10m0s` | `10m0s` |
| `FRAMEWORK_REPO` | `OWNER/NAME` of the framework on Gitea | `TirSystem/SQA-QC-Framework` |
Every URL must start with `https://` and must not contain a user name,
password, query string or fragment. A credential key in this file is rejected.
### `.env` (credentials)
| Key | Meaning |
| --- | --- |
| `GITEA_TOKEN` | Gitea access token (required) |
| `GITHUB_PAT` | GitHub personal access token (only when you choose GitHub) |
| `GITHUB_USER` | the GitHub account the token belongs to; only a default for the owner prompt |
`.env` is ignored by git. The script warns if it is readable by other users or
not ignored by git. See [Token permissions](#token-permissions) for what each
token needs.
## Usage
```bash
src/create-project.sh # dry run: reads from the hosts, creates nothing
src/create-project.sh --apply # creates everything after a final yes
src/create-project.sh --config /path/to/config.env --env /path/to/.env
```
The script asks for, in this order: repository name, description, visibility,
Gitea owner, whether to also create a GitHub repository (and its owner), the
local directory and whether to enable the plan gate. It then checks both hosts
with read-only requests and prints a plan:
```text
Plan:
Gitea repository : create (private) with the AGPL-3.0 license https://git.example.org/Team/my-app
GitHub repository : create (private), empty https://github.com/acme/my-app
Push mirror : Gitea -> GitHub every 10m0s
Local project : create ./my-app (new directory), git on main, no commit
Local origin : will use SSH (the SSH test passed)
Framework : add ssh://git@git.example.org:10022/Team/SQA-QC-Framework.git as a submodule
Skills and hooks : install once; plan gate no
Templates : AGENTS.md and docs/artifact-registry.md (you are asked before a file is replaced)
```
Without `--apply` that is all that happens. With `--apply` the script asks
"Create these now" (default no) and then creates, in this order:
1. the GitHub repository (empty), if chosen;
2. the Gitea repository (with the AGPL-3.0 license if GitHub was chosen);
3. the push mirror Gitea -> GitHub, and a request for its first sync;
4. the local directory, `git init` on `main`, the `origin` remote (and `github`
if chosen), and, if the Gitea repository holds the license commit, that
history;
5. the framework as the submodule `framework`;
6. the framework's skills and git hooks, and the plan gate if chosen;
7. `AGENTS.md` and `docs/artifact-registry.md` from the framework's templates.
No commit is made in the new project. Work on a branch there: the framework's
hooks refuse commits on `main`.
### Choices
- **GitHub or not.** Choosing GitHub also applies the AGPL-3.0 license to the
Gitea repository (so it is not empty) and sets up the mirror. Without GitHub
the Gitea repository is empty and has no license, and `GITHUB_PAT` is not
needed.
- **Owners.** The Gitea owner and the GitHub owner are chosen separately and
may be a user or an organization. `GITHUB_USER` is only the suggested default
for the GitHub owner prompt; it identifies who authenticates.
- **Plan gate.** If enabled, a commit that changes `src/` or `tests/` in the
new project needs a `Task: MIL-NNN#N` trailer.
### Nothing is overwritten without a yes
The script asks first (default no) before it uses an existing directory, before
it replaces an existing `core.hooksPath`, and before it replaces an existing
`AGENTS.md` or `docs/artifact-registry.md`. It never deletes anything, never
replaces a remote that points somewhere else, and git itself refuses to
overwrite a file when the license history is checked out.
## SSH access to Gitea
The framework submodule is fetched over SSH on port **10022**
(`ssh://git@<gitea host>:10022/TirSystem/SQA-QC-Framework.git`). Before you run
the script:
1. Add your SSH public key to your Gitea account.
2. Connect once by hand so that the server's host key is known (the script
refuses unknown host keys and never answers questions for you):
```bash
ssh -p 10022 -T git@git.tirsystem.com
```
A message that you have successfully authenticated, without shell access,
means it works.
The script runs the same check in its dry run. If it fails, the plan says so
and, with `--apply`, you are asked whether to create the repositories and the
local project **without** the framework steps (they are then reported as
skipped). The default answer is no.
## Token permissions
@@ -56,10 +203,8 @@ repositories for the chosen owner and to push to the new one.
- **Fine-grained tokens:** the GitHub documentation lists no fine-grained
permission for creating a repository, and this has not been tested.
Use a classic token until it has been.
- **`GITHUB_USER`:** names the account the token belongs to. It is only a
default for the owner prompt; the repository may belong to an organization.
If it differs from the account the token belongs to, the script warns and
uses the account the token belongs to.
- **`GITHUB_USER`:** if it differs from the account the token belongs to, the
script warns and uses the account the token belongs to.
### Gitea token (`GITEA_TOKEN`)
@@ -68,11 +213,70 @@ repositories for the chosen owner and to push to the new one.
| Read the account the token belongs to | `read:user` | Gitea documentation |
| Create repositories, manage the push mirror | `write:repository` | Gitea documentation |
| Look up an organization and your permissions in it | `read:organization` | Gitea documentation |
| Create a repository in an organization | probably `write:organization` as well | **Not confirmed**: expected from how the Gitea API groups organization calls; the end-to-end test in MIL-003 will confirm it. |
| Create a repository in an organization | probably `write:organization` as well | **Not confirmed**: expected from how the Gitea API groups organization calls; to be confirmed in the first end-to-end run. |
A missing scope shows up as an HTTP 403 with the server's own message. The
script stops before it creates anything when a preflight check is refused.
## Security decisions
- **Tokens never appear** in output, logs, remote URLs, `.git/config`,
`.gitmodules`, command lines or leftover files. They go to `curl` through a
private configuration file that is removed right after the request, and to
`git` (HTTPS fetch only) through a `GIT_ASKPASS` helper and the environment
of that one command. Output is filtered, so even a server message that echoes
a token is shown as `[redacted]`. Tests plant fake tokens and search all
output and every file of the new project for them.
- **No `set -x`.** Tracing would print every secret, so the script switches it
off and says so.
- **Config files are parsed, not sourced,** with a whitelist of keys; values
are validated (URLs must be `https` without credentials, tokens must have a
safe character set) and never executed.
- **Dry run by default.** Creating anything needs `--apply` and a final yes.
- **No destructive commands.** The script never deletes a repository or a
file and never uses a recursive delete; temporary files are removed one by
one.
- **Credential-free remotes.** `origin` is `ssh://git@host:port/owner/name.git`
(or plain HTTPS when SSH is not used) and `github` is a plain HTTPS address.
- **Redirects are not followed,** so a token is only ever sent to the host in
the URL it was meant for. Unknown SSH host keys are refused.
- **Framework scripts run on the new project only.** They are run with
`PROJECT_ROOT` set explicitly, so a `PROJECT_ROOT` in your environment cannot
point them elsewhere. They come from the framework repository you configured:
review what you trust there.
## Error handling and recovery
Every message starts with `error:`, names what failed and what to do, and never
contains a secret. Exit codes: `0` success (or a dry run), `1` a failed check or
step, `2` a usage error.
| What happens | What the script does | What you do |
| --- | --- | --- |
| A tool, a config key or a token is missing or invalid | stops before any request | fix it and run again |
| A token is refused, an owner is unknown, a name is taken, the license is missing | stops in the preflight; nothing was created | fix the cause |
| The host cannot be reached | stops with the host name | try again |
| A repository already exists and is empty (Gitea: or holds only the license) | offers to reuse it (default no) | answer, or choose another name |
| A repository already has content | stops | choose another name or remove it |
| A step fails after another succeeded | stops and prints what exists, what failed and how to continue | fix the cause and run the **same command again with `--apply`**: what was created is offered for reuse |
| The mirror is refused (disabled, interval too short) | keeps the repositories and reports it | change `MIRROR_INTERVAL` or ask the Gitea administrator, then run again |
| The framework submodule cannot be fetched | reports the address and how to test SSH | fix your SSH access, run again |
| `sync_on_commit` was ignored by Gitea | warns; the mirror syncs on its interval | enable it in the repository settings if needed |
A partial run is reported like this:
```text
The run stopped before it finished. This is what exists now:
GitHub repository : created https://github.com/acme/my-app
Gitea repository : FAILED
Push mirror : not attempted
...
To continue: fix the problem named above and run the same command again with --apply.
```
Nothing is deleted automatically. To start over, delete the repositories in the
web interface and the project directory by hand.
## Known limitations
- **The mirror password is stored on the Gitea server.** Gitea needs the
@@ -90,16 +294,15 @@ script stops before it creates anything when a preflight check is refused.
the repositories created so far are kept.
- **The license commit.** Gitea adds the license file when the repository is
created with `auto_init`. The script sends no README, so the repository
should hold only `LICENSE`; this is checked in the MIL-003 end-to-end test.
- **No rollback.** If a step fails, the script reports what exists and how to
continue. A repeated run offers to reuse a repository it created earlier
(empty, or in Gitea's case holding only the license). Delete what you do not
want in the web interface.
should hold only `LICENSE`; this is still to be confirmed against a real
server.
- **No rollback.** See [Error handling and recovery](#error-handling-and-recovery).
- **Mirror direction is Gitea to GitHub only.** Push to Gitea; GitHub is a
copy.
- **Requirements:** bash 4.4 or later, `git`, `curl` and `mktemp`; `jq` and
`ssh` are optional. Without `jq` the script reads the few JSON fields it
needs with a simple built-in reader.
- **The framework needs SSH.** Without SSH access to Gitea the framework steps
can only be skipped.
- **Tested on Windows (Git Bash) only so far.** Running the tests on Linux and
macOS is an open follow-up.
## Code layout
@@ -121,12 +324,15 @@ file. The files are loaded from that directory only, by a fixed path.
| `api.sh` | GitHub and Gitea API calls and reporting a refused call |
| `prompts.sh` | interactive questions with validation |
| `project.sh` | the project details: asking for them and showing them |
| `hosts.sh` | names and links of the repositories on each host |
| `hosts.sh` | names, links and remote addresses of the repositories |
| `preflight.sh` | read-only checks of both hosts |
| `steps.sh` | the outcome of each step and the final report |
| `plan.sh` | printing what the script is about to do |
| `repositories.sh` | creating the GitHub and Gitea repositories |
| `mirror.sh` | the Gitea to GitHub push mirror |
| `git.sh` | running git for the new project without prompts or tokens on a command line |
| `localproject.sh` | the local directory, git repository and remotes |
| `framework.sh` | the framework submodule, skills, hooks and templates |
| `apply.sh` | confirmations and the apply flow; the only code that changes anything |
| `cli.sh` | usage text and option parsing |
@@ -138,5 +344,28 @@ when it is loaded.
## Development
```bash
bash tests/run-tests.sh # shellcheck, shfmt and all tests, no network
bash tests/run-tests.sh # shellcheck, shfmt and all tests, no network
bash tests/run-tests.sh PATTERN # only tests whose name contains PATTERN
```
The tests stub the two host APIs (a fake `curl` answers from a routes file) and
`ssh`, and run real git against local bare repositories that stand in for
Gitea and the framework (git's `insteadOf` rewrites the remote addresses), with
a private git configuration. Nothing reaches the network and nothing outside
the test directories is changed. Mutation checks show that the tests fail when
a guarantee is removed.
Planning documents, reviews and the traceability matrix are in `docs/`; the
project follows the SQA and QC framework (see `AGENTS.md`).
## Stakeholders
| Who | Role |
| --- | --- |
| [Tirsvad](https://www.linkedin.com/in/tirsvad74) | Product Owner and maintainer |
| [Michael Kragh](https://www.linkedin.com/in/codemikemike/) | DevOps, cybersecurity and maintainer |
| GitHub readers | people who read and may reuse this project |
## License
GNU Affero General Public License v3.0; see [LICENSE](LICENSE).
+5
View File
@@ -29,3 +29,8 @@ GITEA_API_URL=https://git.tirsystem.com/api/v1
# Optional. How often Gitea pushes to GitHub, as a Go duration (10m0s, 8h0m0s).
# The server may enforce a minimum. Default: 10m0s.
#MIRROR_INTERVAL=10m0s
# Optional. OWNER/NAME of the SQA-QC-Framework repository on the Gitea server;
# it is added to the new project as a submodule over SSH.
# Default: TirSystem/SQA-QC-Framework.
#FRAMEWORK_REPO=TirSystem/SQA-QC-Framework
+25 -13
View File
@@ -4,12 +4,14 @@
# Purpose
# RepoFoundry creates a Gitea repository, optionally an empty GitHub
# repository with a Gitea -> GitHub push mirror, and a local project with
# the SQA-QC-Framework. This version (MIL-002) validates the configuration
# and credentials, asks for the project details, checks both hosts with
# read-only requests (tokens, owners, names, licence, SSH) and, with
# --apply, creates the repositories and the mirror. Choosing GitHub also
# applies the AGPL-3.0 license to the Gitea repository. The local project
# is not created yet.
# the SQA-QC-Framework. It validates the configuration and credentials,
# asks for the project details, checks both hosts with read-only requests
# (tokens, owners, names, license, SSH) and, with --apply, creates the
# repositories and the mirror, then the local project: its directory, git
# repository, remotes (no credential in any address), the framework as a
# submodule, the framework's skills and git hooks (and the plan gate if
# chosen) and its templates. Choosing GitHub also applies the AGPL-3.0
# license to the Gitea repository. No commit is made in the new project.
#
# Dry run by default
# Without --apply the script only reads from GitHub and Gitea (GET
@@ -31,8 +33,9 @@
#
# Files (parsed, never sourced)
# config.env GITHUB_API_URL, GITHUB_WEB_URL, GITEA_URL, GITEA_API_URL and
# the optional GITEA_SSH_PORT (default 10022) and
# MIRROR_INTERVAL (default 10m0s)
# the optional GITEA_SSH_PORT (default 10022), MIRROR_INTERVAL
# (default 10m0s) and FRAMEWORK_REPO (default
# TirSystem/SQA-QC-Framework, the submodule's OWNER/NAME)
# .env GITHUB_PAT, GITHUB_USER, GITEA_TOKEN
#
# Environment
@@ -44,13 +47,15 @@
# Requires
# bash 4.4 or later, git, curl, mktemp; jq and ssh are optional (jq is used
# for JSON when present; ssh is used for the Gitea SSH test).
# Also the base tools sed, grep, head, tr, sleep, rm, rmdir and uname, and
# Also the base tools sed, grep, head, tr, sleep, find, cp, mkdir, chmod, env, rm,
# rmdir and uname, and
# stat (GNU "stat -c" or BSD "stat -f"; only used outside Windows).
#
# Implements
# MIL-001 tasks 1 to 6 and MIL-002 tasks 1 to 5 (issues #3 to #13), user
# stories US-001.01 and US-001.02, UC-001 steps 1 to 7; see docs/. Deviation
# from the request: its second GITEA_URL key is named GITEA_API_URL.
# MIL-001 tasks 1 to 6, MIL-002 tasks 1 to 5 and MIL-003 tasks 1 to 4
# (issues #3 to #13 and #15 to #18), user stories US-001.01 to US-001.03,
# UC-001 steps 1 to 10; see docs/. Deviation from the request: its second
# GITEA_URL key is named GITEA_API_URL.
#
# Tracing
# set -x is switched off while the script runs, because a trace would print
@@ -61,7 +66,8 @@
# the files in lib/ next to it (one job per file, see the first lines of
# each file): constants, output, temp, util, validate, config, tools, json,
# http, api, prompts, project, hosts, preflight, steps, plan, repositories,
# mirror, apply and cli. The files are loaded from this directory only.
# mirror, git, localproject, framework, apply and cli. The files are loaded
# from this directory only.
#
# Exit codes
# 0 success (or a dry run, or a "no" at the final question), 1 a failed
@@ -129,6 +135,12 @@ source "$SCRIPT_DIR/lib/plan.sh"
source "$SCRIPT_DIR/lib/repositories.sh"
# shellcheck source=lib/mirror.sh
source "$SCRIPT_DIR/lib/mirror.sh"
# shellcheck source=lib/git.sh
source "$SCRIPT_DIR/lib/git.sh"
# shellcheck source=lib/localproject.sh
source "$SCRIPT_DIR/lib/localproject.sh"
# shellcheck source=lib/framework.sh
source "$SCRIPT_DIR/lib/framework.sh"
# shellcheck source=lib/apply.sh
source "$SCRIPT_DIR/lib/apply.sh"
# shellcheck source=lib/cli.sh
+22 -1
View File
@@ -4,7 +4,7 @@
#
# Part of create-project.sh: sourced by it, never run on its own.
#
# Provides: create_all, confirm_reuse, apply_plan
# Provides: create_all, confirm_reuse, confirm_framework_access, apply_plan
create_all() {
IS_CREATION_STARTED=1
@@ -15,6 +15,25 @@ create_all() {
if ((PROJECT[has_github])); then
configure_mirror
fi
create_local_project
add_framework
install_framework
copy_templates
}
# confirm_framework_access: the framework comes over SSH. Without SSH the
# Maintainer can still go on, without the framework steps, after a yes.
confirm_framework_access() {
if ((${STATE[is_ssh_ok]:-0})); then
STATE[skip_framework]=0
return 0
fi
warn "SSH to Gitea ($(gitea_host) port ${CONFIG[GITEA_SSH_PORT]}) did not work, so the framework cannot be added: ${STATE[ssh_note]}"
prompt_yes_no "Create the repositories and the local project without the framework" n
if ! ((REPLY)); then
die "stopped: set up SSH access to Gitea (see the README) and run again"
fi
STATE[skip_framework]=1
}
confirm_reuse() {
@@ -42,5 +61,7 @@ apply_plan() {
return 0
fi
confirm_reuse
confirm_local_directory
confirm_framework_access
create_all
}
+3
View File
@@ -100,6 +100,9 @@ validate_config() {
CONFIG[MIRROR_INTERVAL]="${CONFIG[MIRROR_INTERVAL]:-$DEFAULT_MIRROR_INTERVAL}"
is_valid_interval "${CONFIG[MIRROR_INTERVAL]}" ||
die "MIRROR_INTERVAL in $CONFIG_FILE must look like 10m0s or 8h0m0s"
CONFIG[FRAMEWORK_REPO]="${CONFIG[FRAMEWORK_REPO]:-$DEFAULT_FRAMEWORK_REPO}"
is_valid_framework_repo "${CONFIG[FRAMEWORK_REPO]}" ||
die "FRAMEWORK_REPO in $CONFIG_FILE must look like OWNER/NAME"
}
validate_credentials() {
+5 -3
View File
@@ -8,7 +8,7 @@
# shellcheck disable=SC2034 # read and written by the other library files
readonly PROJECT_NAME="${REPOFOUNDRY_NAME:-RepoFoundry}"
readonly VERSION="0.2.0"
readonly VERSION="0.3.0"
readonly EXIT_FAILURE=1
readonly EXIT_USAGE=2
readonly MAX_VALUE_LENGTH=2048
@@ -16,12 +16,14 @@ readonly MAX_DESCRIPTION_LENGTH=350
readonly HTTP_TIMEOUT_SECONDS=30
readonly DEFAULT_MIRROR_INTERVAL="10m0s"
readonly DEFAULT_SSH_PORT=10022
readonly DEFAULT_FRAMEWORK_REPO="TirSystem/SQA-QC-Framework"
readonly AGPL_LICENSE_KEY="AGPL-3.0"
readonly DEFAULT_BRANCH="main"
readonly PLAN_STEPS=("GitHub repository" "Gitea repository" "Push mirror")
readonly PLAN_STEPS=("GitHub repository" "Gitea repository" "Push mirror"
"Local project" "Framework" "Skills and hooks" "Templates")
# shellcheck disable=SC2034 # read through namerefs (parse_env_file)
readonly CONFIG_KEYS=(GITHUB_API_URL GITHUB_WEB_URL GITEA_URL GITEA_API_URL
GITEA_SSH_PORT MIRROR_INTERVAL)
GITEA_SSH_PORT MIRROR_INTERVAL FRAMEWORK_REPO)
readonly CREDENTIAL_KEYS=(GITHUB_PAT GITHUB_USER GITEA_TOKEN)
CONFIG_FILE="$PROJECT_ROOT/config.env"
+153
View File
@@ -0,0 +1,153 @@
# shellcheck shell=bash
# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh
# framework.sh - The SQA-QC-Framework in the new project: submodule, skills, hooks and templates.
#
# Part of create-project.sh: sourced by it, never run on its own.
#
# Provides: is_framework_skipped, add_framework, run_framework_script, install_skills, install_hooks, install_framework, copy_template, copy_templates
# is_framework_skipped: succeed when the framework steps are left out because
# SSH to Gitea is not available (the Maintainer agreed to that).
is_framework_skipped() {
[[ ${STATE[skip_framework]:-0} == 1 ]]
}
# add_framework: git submodule add of the framework as "framework". SSH is
# needed for it; a failure says how to test the access.
add_framework() {
local label="Framework" dir="${PROJECT[directory]}" url err existing
url="$(framework_url)"
if is_framework_skipped; then
finish_step "$label" "skipped" "(no SSH access to Gitea)"
return 0
fi
begin_step "$label"
if [[ -e $dir/framework ]]; then
existing="$(git_project "$dir" config -f .gitmodules --get submodule.framework.url || true)"
if [[ $existing != "$url" ]]; then
die "'framework' already exists in $dir and is not the framework submodule ($url)"
fi
finish_step "$label" "reused" "$url (already a submodule)"
return 0
fi
make_temp_file
err="$REPLY"
if ! git_project "$dir" submodule add -q "$url" framework 2>"$err"; then
die "git could not add the framework from $url. Check the SSH access first: ssh -p ${CONFIG[GITEA_SSH_PORT]} -T git@$(gitea_host). Git said: $(head -n 2 "$err" | tr '\n' ' ')"
fi
finish_step "$label" "created" "$url"
}
# run_framework_script DIR SCRIPT [ARG...]: run one of the framework's own
# scripts inside the project. PROJECT_ROOT is set explicitly so that a
# PROJECT_ROOT in the caller's environment cannot point it elsewhere.
run_framework_script() {
local dir="$1" script="$2" out abs
shift 2
abs="$(cd "$dir" && pwd)"
make_temp_file
out="$REPLY"
if ! (cd "$abs" && env PROJECT_ROOT="$abs" bash "framework/scripts/$script" "$@") >"$out" 2>&1; then
die "the framework script $script failed: $(tail -n 3 "$out" | tr '\n' ' ')"
fi
}
# install_skills DIR: install the framework's skills once. The installer is
# safe to repeat but copies everything again, so a project that already has
# them is left alone.
install_skills() {
local dir="$1"
if [[ -f $dir/.agents/skills/.framework-skills && -f $dir/.claude/skills/.framework-skills ]]; then
STATE[skills_note]="skills already installed"
return 0
fi
run_framework_script "$dir" install-skills.sh
STATE[skills_note]="skills installed"
}
# install_hooks DIR: point core.hooksPath at the framework's hooks, and turn
# on the plan gate if chosen. A different hooks path that is already set is
# only replaced after a yes.
install_hooks() {
local dir="$1" current global gate=() gate_enabled
if ((PROJECT[is_plan_gate_enabled])); then
gate=(--enable-plan-gate)
fi
# Both settings are normally unset; git config exits 1 then.
current="$(git_project "$dir" config --local --get core.hooksPath || true)"
global="$(git_project "$dir" config --global --get core.hooksPath || true)"
gate_enabled="$(git_project "$dir" config --local --get planGate.enabled || true)"
if [[ -z $current && -n $global ]]; then
warn "your global core.hooksPath is '$global'; this project sets its own, which takes precedence here"
fi
if [[ -z $current ]]; then
run_framework_script "$dir" install-git-hooks.sh "${gate[@]}"
STATE[hooks_note]="hooks installed"
elif [[ $current == framework/githooks ]]; then
STATE[hooks_note]="hooks already installed"
if ((PROJECT[is_plan_gate_enabled])) && [[ $gate_enabled != true ]]; then
run_framework_script "$dir" install-git-hooks.sh "${gate[@]}"
fi
else
prompt_yes_no "core.hooksPath is already '$current'. Replace it with framework/githooks" n
if ((REPLY)); then
run_framework_script "$dir" install-git-hooks.sh "${gate[@]}"
STATE[hooks_note]="hooks installed (replaced '$current')"
else
STATE[hooks_note]="kept the existing hooks path '$current'"
if ((PROJECT[is_plan_gate_enabled])); then
warn "the plan gate is not enabled because the framework hooks were not installed"
fi
fi
fi
}
# install_framework: skills, then hooks (and the plan gate). Each is done once.
install_framework() {
local label="Skills and hooks" dir="${PROJECT[directory]}" gate_state="plan gate off"
if is_framework_skipped; then
finish_step "$label" "skipped" "(no SSH access to Gitea)"
return 0
fi
begin_step "$label"
install_skills "$dir"
install_hooks "$dir"
if [[ $(git_project "$dir" config --local --get planGate.enabled || true) == true ]]; then
gate_state="plan gate on"
fi
finish_step "$label" "created" "(${STATE[skills_note]}; ${STATE[hooks_note]}; $gate_state)"
}
# copy_template DIR SOURCE TARGET: copy a framework template. An existing
# target is only replaced after a yes. The result goes to STATE[template_note].
copy_template() {
local dir="$1" source="$2" target="$3"
if [[ ! -f $dir/$source ]]; then
die "the framework has no $source; is the submodule complete?"
fi
if [[ -e $dir/$target ]]; then
prompt_yes_no "$target already exists. Replace it with the framework template" n
if ! ((REPLY)); then
STATE[template_note]="kept existing $target"
return 0
fi
fi
cp -- "$dir/$source" "$dir/$target"
STATE[template_note]="copied $target"
}
# copy_templates: AGENTS.md and docs/artifact-registry.md from the framework.
copy_templates() {
local label="Templates" dir="${PROJECT[directory]}" notes=""
if is_framework_skipped; then
finish_step "$label" "skipped" "(no SSH access to Gitea)"
return 0
fi
begin_step "$label"
mkdir -p -- "$dir/docs"
copy_template "$dir" framework/templates/AGENTS-template.md AGENTS.md
notes="${STATE[template_note]}"
copy_template "$dir" framework/templates/artifact-registry-template.md docs/artifact-registry.md
notes="$notes; ${STATE[template_note]}"
finish_step "$label" "created" "($notes)"
}
+48
View File
@@ -0,0 +1,48 @@
# shellcheck shell=bash
# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh
# git.sh - Running git for the new project: no prompts, no token on a command line.
#
# Part of create-project.sh: sourced by it, never run on its own.
#
# Provides: git_project, fetch_origin
# git_project DIR ARGS...: run git in DIR. Prompts are switched off, so a
# missing credential or SSH key fails at once instead of waiting for input.
git_project() {
local dir="$1"
shift
GIT_TERMINAL_PROMPT=0 GIT_SSH_COMMAND="${GIT_SSH_COMMAND:-ssh} -o BatchMode=yes" \
git -C "$dir" "$@"
}
# fetch_origin DIR: fetch the Gitea repository into the project. Over SSH the
# user's key is used. Over HTTPS the token reaches git through a private
# GIT_ASKPASS helper and the environment of this one command: it is never part
# of a URL, of the remote configuration or of a command line.
fetch_origin() {
local dir="$1" url askpass
url="$(git_project "$dir" config --get remote.origin.url)"
if [[ $url != https://* ]]; then
git_project "$dir" fetch -q origin
return
fi
make_temp_file
askpass="$REPLY"
# shellcheck disable=SC2016 # the helper is written out literally: it expands $1 and its environment itself
{
printf '%s\n' '#!/usr/bin/env bash'
printf '%s\n' 'case "$1" in'
printf '%s\n' ' *sername*) printf "%s\n" "$REPOFOUNDRY_ASKPASS_USER" ;;'
printf '%s\n' ' *) printf "%s\n" "$REPOFOUNDRY_ASKPASS_TOKEN" ;;'
printf '%s\n' 'esac'
} >"$askpass"
chmod 700 "$askpass"
# Not "cmd; rm": a failed fetch must still be reported to the caller.
if ! GIT_ASKPASS="$askpass" REPOFOUNDRY_ASKPASS_USER="${STATE[gitea_login]}" \
REPOFOUNDRY_ASKPASS_TOKEN="${CREDENTIALS[GITEA_TOKEN]}" \
git_project "$dir" fetch -q origin; then
rm -f -- "$askpass"
return 1
fi
rm -f -- "$askpass"
}
+38 -1
View File
@@ -4,7 +4,7 @@
#
# Part of create-project.sh: sourced by it, never run on its own.
#
# Provides: is_reused, repo_owner, repo_url
# Provides: is_reused, repo_owner, repo_url, gitea_host, origin_protocol, origin_url, github_remote_url, framework_url
# is_reused HOST: succeed if the existing repository on HOST will be reused.
is_reused() {
@@ -26,3 +26,40 @@ repo_url() {
printf '%s/%s/%s' "${CONFIG[GITHUB_WEB_URL]}" "${PROJECT[github_owner]}" "${PROJECT[name]}"
fi
}
# gitea_host: the host name of the Gitea server, from GITEA_URL.
gitea_host() {
local host="${CONFIG[GITEA_URL]#https://}"
host="${host%%/*}"
printf '%s' "${host%%:*}"
}
# origin_protocol: SSH when the SSH test passed, otherwise HTTPS.
origin_protocol() {
if ((${STATE[is_ssh_ok]:-0})); then
printf 'SSH'
else
printf 'HTTPS'
fi
}
# origin_url: the address of the Gitea repository for the origin remote. It
# never holds a credential: "git@" is the SSH user name, not a secret.
origin_url() {
if [[ $(origin_protocol) == SSH ]]; then
printf 'ssh://git@%s:%s/%s/%s.git' "$(gitea_host)" "${CONFIG[GITEA_SSH_PORT]}" \
"${PROJECT[gitea_owner]}" "${PROJECT[name]}"
else
printf '%s/%s/%s.git' "${CONFIG[GITEA_URL]}" "${PROJECT[gitea_owner]}" "${PROJECT[name]}"
fi
}
github_remote_url() {
printf '%s/%s/%s.git' "${CONFIG[GITHUB_WEB_URL]}" "${PROJECT[github_owner]}" "${PROJECT[name]}"
}
# framework_url: where the framework submodule comes from (always SSH).
framework_url() {
printf 'ssh://git@%s:%s/%s.git' "$(gitea_host)" "${CONFIG[GITEA_SSH_PORT]}" \
"${CONFIG[FRAMEWORK_REPO]}"
}
+90
View File
@@ -0,0 +1,90 @@
# shellcheck shell=bash
# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh
# localproject.sh - The local project: its directory, its git repository and its remotes.
#
# Part of create-project.sh: sourced by it, never run on its own.
#
# Provides: inspect_local_directory, confirm_local_directory, ensure_remote, checkout_gitea_history, create_local_project
# inspect_local_directory: record in STATE[local_dir] whether the project
# directory is missing, empty or not_empty. It creates nothing.
inspect_local_directory() {
local dir="${PROJECT[directory]}"
if [[ ! -e $dir ]]; then
STATE[local_dir]="missing"
elif [[ ! -d $dir ]]; then
die "$dir already exists and is not a directory"
elif [[ -z "$(find "$dir" -mindepth 1 -maxdepth 1 -print -quit)" ]]; then
STATE[local_dir]="empty"
else
STATE[local_dir]="not_empty"
fi
}
# confirm_local_directory: an existing directory is only used after a yes.
confirm_local_directory() {
local dir="${PROJECT[directory]}" what="is empty"
if [[ ${STATE[local_dir]} == missing ]]; then
return 0
fi
if [[ ${STATE[local_dir]} == not_empty ]]; then
what="already has files"
fi
prompt_yes_no "The directory $dir already exists and $what. Use it" n
if ! ((REPLY)); then
die "stopped: choose another directory or remove this one"
fi
}
# ensure_remote DIR NAME URL: add the remote, or accept one that already has
# exactly this address. A different address is never overwritten.
ensure_remote() {
local dir="$1" name="$2" url="$3" existing
# git config exits 1 when the remote is not set; that is the normal case.
existing="$(git_project "$dir" config --get "remote.$name.url" || true)"
if [[ -z $existing ]]; then
git_project "$dir" remote add "$name" "$url"
elif [[ $existing != "$url" ]]; then
die "the remote '$name' in $dir already points to $existing; remove it or choose another directory"
fi
}
# checkout_gitea_history DIR: when the Gitea repository holds the license
# commit, fetch it and start the local branch from it, so the local history
# begins with that commit. Existing files are never overwritten: git refuses.
checkout_gitea_history() {
local dir="$1" err
if ! fetch_origin "$dir" 2>/dev/null; then
die "could not fetch the Gitea repository from $(origin_url); check your SSH key (or, over HTTPS, the token) and run the same command again"
fi
if ! git_project "$dir" rev-parse --verify -q refs/remotes/origin/main >/dev/null; then
return 0
fi
if git_project "$dir" rev-parse --verify -q HEAD >/dev/null 2>&1; then
warn "$dir already has history: the Gitea content was fetched but not checked out"
return 0
fi
make_temp_file
err="$REPLY"
if ! git_project "$dir" checkout -q -b main --track origin/main 2>"$err"; then
die "git would overwrite files in $dir with the Gitea content; move them away and run again. Git said: $(head -n 2 "$err" | tr '\n' ' ')"
fi
}
# create_local_project: the directory, the git repository on main, the
# remotes and, when GitHub is chosen, the license history. No commit is made.
create_local_project() {
local label="Local project" dir="${PROJECT[directory]}"
begin_step "$label"
mkdir -p -- "$dir"
if [[ ! -e $dir/.git ]]; then
git_project "$dir" init -q
git_project "$dir" symbolic-ref HEAD "refs/heads/$DEFAULT_BRANCH"
fi
ensure_remote "$dir" origin "$(origin_url)"
if ((PROJECT[has_github])); then
ensure_remote "$dir" github "$(github_remote_url)"
checkout_gitea_history "$dir"
fi
finish_step "$label" "created" "$dir (origin over $(origin_protocol))"
}
+20 -2
View File
@@ -4,7 +4,17 @@
#
# Part of create-project.sh: sourced by it, never run on its own.
#
# Provides: print_plan
# Provides: local_plan_note, print_plan
# local_plan_note: what will happen to the project directory.
local_plan_note() {
local dir="${PROJECT[directory]}"
case "${STATE[local_dir]}" in
missing) printf 'create %s (new directory), git on %s, no commit' "$dir" "$DEFAULT_BRANCH" ;;
empty) printf 'use the existing empty directory %s (you will be asked)' "$dir" ;;
*) printf 'use the existing directory %s, which has files (you will be asked)' "$dir" ;;
esac
}
print_plan() {
local gitea_action github_action origin_note
@@ -35,5 +45,13 @@ print_plan() {
else
origin_note="HTTPS (SSH test: ${STATE[ssh_note]})"
fi
say "$(printf ' %-18s: %s' "Local origin" "will use $origin_note, in a later phase")"
say "$(printf ' %-18s: %s' "Local project" "$(local_plan_note)")"
say "$(printf ' %-18s: %s' "Local origin" "will use $origin_note")"
if ((STATE[is_ssh_ok])); then
say "$(printf ' %-18s: %s' "Framework" "add $(framework_url) as a submodule")"
say "$(printf ' %-18s: %s' "Skills and hooks" "install once; plan gate $(yes_no "${PROJECT[is_plan_gate_enabled]}")")"
say "$(printf ' %-18s: %s' "Templates" "AGENTS.md and docs/artifact-registry.md (you are asked before a file is replaced)")"
else
say "$(printf ' %-18s: %s' "Framework" "NOT possible without SSH to Gitea; you will be asked whether to go on without it")"
fi
}
+2 -3
View File
@@ -108,9 +108,7 @@ preflight_github() {
# or without access it is simply "not passed"; it never stops the run.
test_gitea_ssh() {
local host port output status=0
host="${CONFIG[GITEA_URL]#https://}"
host="${host%%/*}"
host="${host%%:*}"
host="$(gitea_host)"
port="${CONFIG[GITEA_SSH_PORT]}"
STATE[is_ssh_ok]=0
STATE[ssh_note]="failed (check your SSH key and that $host:$port is reachable)"
@@ -163,5 +161,6 @@ run_preflight() {
fi
test_gitea_ssh
decide_existing_repositories
inspect_local_directory
say "All checks passed."
}
+3 -2
View File
@@ -42,10 +42,11 @@ report_outcome() {
say "$(printf ' %-18s: %s' "$label" "${STEP_STATUS[$label]}${STEP_DETAIL[$label]:+ ${STEP_DETAIL[$label]}}")"
done
if ((code == 0)); then
say "The local project with the framework is created by a later phase."
say "The project is in ${PROJECT[directory]}. Nothing was committed there: review it, then work on a branch."
else
say "To continue: fix the problem named above and run the same command again with --apply."
say "A repository this run created is still empty (or holds only the license), so the next run offers to reuse it."
say "Nothing is deleted automatically. To start over, delete the repositories above in the web interface."
say "A directory, remotes and submodule created so far are used again by the next run; you are asked before an existing directory or file is touched."
say "Nothing is deleted automatically. To start over, delete the repositories above in the web interface and the project directory by hand."
fi
}
+6 -1
View File
@@ -4,7 +4,7 @@
#
# Part of create-project.sh: sourced by it, never run on its own.
#
# Provides: is_valid_repo_name, is_valid_gitea_owner, is_valid_github_owner, is_valid_description, is_valid_directory, is_valid_base_url, is_valid_request_url, is_valid_token, is_valid_port, is_valid_interval, normalize_url
# Provides: is_valid_framework_repo, is_valid_repo_name, is_valid_gitea_owner, is_valid_github_owner, is_valid_description, is_valid_directory, is_valid_base_url, is_valid_request_url, is_valid_token, is_valid_port, is_valid_interval, normalize_url
is_valid_repo_name() {
local name="$1"
@@ -49,6 +49,11 @@ is_valid_token() {
[[ $1 =~ ^[A-Za-z0-9_.~+/=-]{8,255}$ ]]
}
# OWNER/NAME of the framework repository on Gitea.
is_valid_framework_repo() {
[[ $1 =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ && $1 != */.. && $1 != ../* && $1 != ./* && $1 != */. ]]
}
is_valid_port() {
[[ $1 =~ ^[0-9]{1,5}$ ]] && ((10#$1 >= 1 && 10#$1 <= 65535))
}
+65 -6
View File
@@ -23,6 +23,7 @@ readonly FAKE_GITHUB_PAT="ghpFAKEtoken1234567890"
readonly ANSWERS_GITHUB=$'my-app\nA test app\n\nTirSystem\ny\nacme-org\n\nn\n'
readonly ANSWERS_GITEA_ONLY=$'my-app\n\n\nTirSystem\nn\n\nn\n'
SHARED_REMOTES=""
TESTS_RUN=0
TESTS_FAILED=0
CURRENT_TEST=""
@@ -91,6 +92,61 @@ assert_file_missing() {
new_workdir() {
WORK="$(mktemp -d "${TMPDIR:-/tmp}/repofoundry-test.XXXXXX")"
mkdir -p "$WORK/bin" "$WORK/tmp"
write_gitconfig
}
# write_gitconfig: the git configuration every test run uses instead of the
# real user's (GIT_CONFIG_GLOBAL), so no test depends on or changes it. The
# remote addresses of Gitea and the framework are redirected to local bare
# repositories (see setup_local_remotes); nothing reaches the network.
write_gitconfig() {
cat >"$WORK/gitconfig" <<EOF
[user]
name = Test User
email = test@example.test
[protocol "file"]
allow = always
[url "file://$WORK/remote/"]
insteadOf = https://git.example.test/
[url "file://$WORK/remote/"]
insteadOf = ssh://git@git.example.test:10022/
EOF
}
# ensure_shared_remotes: build, once per run of the suite, the local bare
# repositories that stand in for Gitea (TirSystem/my-app.git, holding the
# license commit) and for the framework (a copy of the real one).
ensure_shared_remotes() {
if [[ -n $SHARED_REMOTES && -d $SHARED_REMOTES ]]; then
return 0
fi
SHARED_REMOTES="$(mktemp -d "${TMPDIR:-/tmp}/repofoundry-remotes.XXXXXX")"
mkdir -p "$SHARED_REMOTES/TirSystem"
git clone -q --bare "$REPO_ROOT/framework" "$SHARED_REMOTES/TirSystem/SQA-QC-Framework.git"
git init -q --bare "$SHARED_REMOTES/TirSystem/my-app.git"
git init -q "$SHARED_REMOTES/seed"
git -C "$SHARED_REMOTES/seed" symbolic-ref HEAD refs/heads/main
printf 'GNU AFFERO GENERAL PUBLIC LICENSE (test copy)\n' >"$SHARED_REMOTES/seed/LICENSE"
git -C "$SHARED_REMOTES/seed" add LICENSE
git -c user.name=Seed -c user.email=seed@example.test -C "$SHARED_REMOTES/seed" commit -q -m "Initial commit"
git -C "$SHARED_REMOTES/seed" push -q "$SHARED_REMOTES/TirSystem/my-app.git" main
find "$SHARED_REMOTES/seed" \( -type f -o -type l \) -delete
find "$SHARED_REMOTES/seed" -depth -type d -exec rmdir {} +
}
# remove_shared_remotes: delete the shared repositories at the end of the run.
remove_shared_remotes() {
if [[ -n $SHARED_REMOTES && -d $SHARED_REMOTES ]]; then
find "$SHARED_REMOTES" \( -type f -o -type l \) -delete
find "$SHARED_REMOTES" -depth -type d -exec rmdir {} +
fi
SHARED_REMOTES=""
}
# setup_local_remotes: this test's own copy of the local remotes.
setup_local_remotes() {
ensure_shared_remotes
cp -R "$SHARED_REMOTES" "$WORK/remote"
}
# remove_workdir: delete the work directory without a recursive rm: files
@@ -245,6 +301,7 @@ setup_hosts() {
write_curl_stub
write_ssh_stub 0
write_happy_routes
setup_local_remotes
}
# calls: the "METHOD URL" lines the stub curl received (empty if none).
@@ -260,9 +317,11 @@ run_cli() {
local input="$1"
shift
STATUS=0
PATH="$WORK/bin:$PATH" STUB_DIR="$WORK" TMPDIR="$WORK/tmp" \
REPOFOUNDRY_SYNC_WAIT=0 \
"$BASH" "$SCRIPT" "$@" <<<"$input" >"$WORK/out.txt" 2>"$WORK/err.txt" ||
# Run inside the work directory: a relative project directory such as
# ./my-app is then created there, never in the repository.
(cd "$WORK" && PATH="$WORK/bin:$PATH" STUB_DIR="$WORK" TMPDIR="$WORK/tmp" \
REPOFOUNDRY_SYNC_WAIT=0 GIT_CONFIG_GLOBAL="$WORK/gitconfig" GIT_CONFIG_NOSYSTEM=1 \
"$BASH" "$SCRIPT" "$@" <<<"$input" >"$WORK/out.txt" 2>"$WORK/err.txt") ||
STATUS=$?
OUT="$(cat "$WORK/out.txt")"
ERR="$(cat "$WORK/err.txt")"
@@ -277,9 +336,9 @@ run_lib() {
printf '#!/usr/bin/env bash\nsource "%s"\n' "$SCRIPT"
printf '%s\n' "$2"
} >"$WORK/snippet.sh"
PATH="$WORK/bin:$PATH" STUB_DIR="$WORK" TMPDIR="$WORK/tmp" \
REPOFOUNDRY_SYNC_WAIT=0 \
"$BASH" "$WORK/snippet.sh" <<<"$input" >"$WORK/out.txt" 2>"$WORK/err.txt" ||
(cd "$WORK" && PATH="$WORK/bin:$PATH" STUB_DIR="$WORK" TMPDIR="$WORK/tmp" \
REPOFOUNDRY_SYNC_WAIT=0 GIT_CONFIG_GLOBAL="$WORK/gitconfig" GIT_CONFIG_NOSYSTEM=1 \
"$BASH" "$WORK/snippet.sh" <<<"$input" >"$WORK/out.txt" 2>"$WORK/err.txt") ||
STATUS=$?
OUT="$(cat "$WORK/out.txt")"
ERR="$(cat "$WORK/err.txt")"
+1
View File
@@ -68,6 +68,7 @@ for test_file in "$TEST_DIR"/test-*.sh; do
done < <(declare -F | awk '$3 ~ /^test_/ {print $3}')
done
remove_shared_remotes
printf '\n%d checks, %d failed, %d static check(s) failed\n' \
"$TESTS_RUN" "$TESTS_FAILED" "$failed_checks"
if ((TESTS_FAILED > 0 || failed_checks > 0)); then
+3
View File
@@ -264,6 +264,9 @@ test_apply_declined_creates_nothing() {
test_apply_for_user_owners_uses_the_user_endpoints() {
setup_hosts
# The Gitea account's own repository (with the license commit) is a copy.
mkdir -p "$WORK/remote/gitea-user"
cp -R "$WORK/remote/TirSystem/my-app.git" "$WORK/remote/gitea-user/my-app.git"
write_routes <<'ROUTES'
GET|/api/v1/user|200|{"login":"gitea-user"}
GET|/api/v1/licenses|200|[{"key":"AGPL-3.0"}]
+431
View File
@@ -0,0 +1,431 @@
#!/usr/bin/env bash
# test-local.sh - tests for the local project (MIL-003): the directory, the
# git repository and its remotes, the framework submodule, skills, hooks and
# plan gate, and the templates. Real git runs here, against local bare
# repositories that stand in for Gitea and the framework (git rewrites the
# remote addresses, see write_gitconfig); only the two hosts' APIs are stubs.
# Sourced by run-tests.sh.
# shellcheck disable=SC2016 # snippet and fixture text is literal on purpose
# local_answers DIR GITHUB GATE: the prompt answers; the result is in
# LOCAL_ANSWERS (kept in a variable because $(...) would drop the last newline).
local_answers() {
if [[ $2 == y ]]; then
printf -v LOCAL_ANSWERS 'my-app\nA test app\n\nTirSystem\ny\nacme-org\n%s\n%s\n' "$1" "$3"
else
printf -v LOCAL_ANSWERS 'my-app\n\n\nTirSystem\nn\n%s\n%s\n' "$1" "$3"
fi
}
# project_git DIR ARGS...: git in the project with the tests' own config.
project_git() {
local dir="$1"
shift
GIT_CONFIG_GLOBAL="$WORK/gitconfig" GIT_CONFIG_NOSYSTEM=1 git -C "$dir" "$@"
}
# files_with_secret DIR: any file below DIR (the .git folder included) that
# holds one of the fake tokens.
files_with_secret() {
grep -rlF -e "$FAKE_GITEA_TOKEN" -e "$FAKE_GITHUB_PAT" "$1" 2>/dev/null || true
}
readonly SSH_FRAMEWORK_URL="ssh://git@git.example.test:10022/TirSystem/SQA-QC-Framework.git"
# ----------------------------------------------------- directory and remotes
test_local_project_gets_credential_free_remotes_and_the_license_history() {
setup_hosts
local dir="$WORK/project"
local_answers "$dir" y n
run_apply "$LOCAL_ANSWERS"$'y\n'
assert_status "apply" 0 "$STATUS"
assert_file_exists "directory created" "$dir/.git"
assert_eq "branch is main" "main" "$(project_git "$dir" symbolic-ref --short HEAD)"
assert_eq "origin over SSH, no credential" "ssh://git@git.example.test:10022/TirSystem/my-app.git" "$(project_git "$dir" config --get remote.origin.url)"
assert_eq "github remote over HTTPS, no credential" "https://github.com/acme-org/my-app.git" "$(project_git "$dir" config --get remote.github.url)"
assert_eq "the license commit is the whole history" "1" "$(project_git "$dir" rev-list --count HEAD)"
assert_eq "it is the Gitea commit" "Initial commit" "$(project_git "$dir" log -1 --format=%s)"
assert_file_exists "LICENSE from Gitea" "$dir/LICENSE"
assert_eq "branch follows origin" "origin" "$(project_git "$dir" config --get branch.main.remote)"
assert_eq "no token in any file of the project" "" "$(files_with_secret "$dir")"
assert_contains "reported" "$OUT" "Local project : created $dir (origin over SSH)"
}
test_gitea_only_project_has_no_github_remote_and_no_commit() {
setup_hosts
printf 'GITEA_TOKEN=%s\n' "$FAKE_GITEA_TOKEN" >"$WORK/.env"
local dir="$WORK/project"
local_answers "$dir" n n
run_apply "$LOCAL_ANSWERS"$'y\n'
assert_status "apply" 0 "$STATUS"
assert_eq "origin" "ssh://git@git.example.test:10022/TirSystem/my-app.git" "$(project_git "$dir" config --get remote.origin.url)"
assert_eq "no github remote" "" "$(project_git "$dir" config --get remote.github.url || true)"
assert_file_missing "no license file" "$dir/LICENSE"
assert_eq "no commit was made" "0" "$(project_git "$dir" rev-list --all --count)"
assert_eq "no token in any file" "" "$(files_with_secret "$dir")"
}
test_existing_directory_is_used_only_after_a_yes() {
setup_hosts
local dir="$WORK/project"
mkdir -p "$dir"
printf 'mine\n' >"$dir/keep.txt"
local_answers "$dir" y n
run_apply "$LOCAL_ANSWERS"$'y\nn\n'
assert_status "answered no" 1 "$STATUS"
assert_contains "asked" "$ERR" "The directory $dir already exists and already has files. Use it"
assert_contains "stopped" "$ERR" "stopped: choose another directory or remove this one"
assert_file_missing "nothing added to the directory" "$dir/.git"
assert_not_contains "no repository created before the answer" "$(calls)" "POST"
run_apply "$LOCAL_ANSWERS"$'y\ny\n'
assert_status "answered yes" 0 "$STATUS"
assert_eq "the existing file is untouched" "mine" "$(cat "$dir/keep.txt")"
assert_file_exists "project created beside it" "$dir/.git"
}
test_an_empty_existing_directory_is_also_confirmed() {
setup_hosts
local dir="$WORK/project"
mkdir -p "$dir"
local_answers "$dir" y n
run_apply "$LOCAL_ANSWERS"$'y\ny\n'
assert_status "empty directory, yes" 0 "$STATUS"
assert_contains "asked" "$ERR" "already exists and is empty. Use it"
}
test_a_file_that_would_be_overwritten_by_the_license_history_is_kept() {
setup_hosts
local dir="$WORK/project"
mkdir -p "$dir"
printf 'my own license\n' >"$dir/LICENSE"
local_answers "$dir" y n
run_apply "$LOCAL_ANSWERS"$'y\ny\n'
assert_status "git refuses to overwrite" 1 "$STATUS"
assert_contains "says why" "$ERR" "git would overwrite files in $dir"
assert_eq "the file is intact" "my own license" "$(cat "$dir/LICENSE")"
assert_contains "step failed" "$OUT" "Local project : FAILED"
assert_contains "later steps not attempted" "$OUT" "Framework : not attempted"
}
test_a_remote_with_another_address_is_never_replaced() {
setup_hosts
local dir="$WORK/project"
mkdir -p "$dir"
project_git "$dir" init -q
project_git "$dir" remote add origin https://elsewhere.example.test/x/y.git
local_answers "$dir" y n
run_apply "$LOCAL_ANSWERS"$'y\ny\n'
assert_status "different origin" 1 "$STATUS"
assert_contains "says so" "$ERR" "the remote 'origin' in $dir already points to https://elsewhere.example.test/x/y.git"
assert_eq "origin unchanged" "https://elsewhere.example.test/x/y.git" "$(project_git "$dir" config --get remote.origin.url)"
}
# ---------------------------------------------------------------- framework
test_framework_is_a_submodule_and_installed_in_order() {
setup_hosts
local dir="$WORK/project"
local_answers "$dir" y n
run_apply "$LOCAL_ANSWERS"$'y\n'
assert_status "apply" 0 "$STATUS"
assert_eq "submodule address" "$SSH_FRAMEWORK_URL" "$(project_git "$dir" config -f .gitmodules --get submodule.framework.url)"
assert_file_exists "submodule content" "$dir/framework/scripts/install-skills.sh"
assert_file_exists "skills installed" "$dir/.claude/skills/coding-conventions/SKILL.md"
assert_file_exists "skills for the other harness" "$dir/.agents/skills/.framework-skills"
assert_eq "hooks path" "framework/githooks" "$(project_git "$dir" config --local --get core.hooksPath)"
assert_eq "plan gate off" "" "$(project_git "$dir" config --local --get planGate.enabled || true)"
assert_contains "reported" "$OUT" "Framework : created $SSH_FRAMEWORK_URL"
assert_contains "reported once" "$OUT" "Skills and hooks : created (skills installed; hooks installed; plan gate off)"
}
test_skills_and_hooks_are_installed_once() {
setup_hosts
local dir="$WORK/project"
local_answers "$dir" y n
run_apply "$LOCAL_ANSWERS"$'y\n'
assert_status "first run" 0 "$STATUS"
run_lib "" "PROJECT[directory]='$dir'
PROJECT[is_plan_gate_enabled]=0
install_framework
echo \"\${STATE[skills_note]}|\${STATE[hooks_note]}\""
assert_status "second pass" 0 "$STATUS"
assert_eq "nothing installed twice" "skills already installed|hooks already installed" "$OUT"
}
test_the_plan_gate_is_optional_and_refuses_unplanned_commits() {
setup_hosts
local dir="$WORK/project" out
local_answers "$dir" y y
run_apply "$LOCAL_ANSWERS"$'y\n'
assert_status "apply with the plan gate" 0 "$STATUS"
assert_eq "plan gate on" "true" "$(project_git "$dir" config --local --get planGate.enabled)"
assert_contains "reported" "$OUT" "plan gate on)"
# The hooks refuse a commit on main, so work on a branch.
project_git "$dir" checkout -q -b work
mkdir -p "$dir/src"
printf 'x\n' >"$dir/src/x.txt"
project_git "$dir" add src/x.txt
out="$(project_git "$dir" commit -q -m "unplanned change" 2>&1 || true)"
assert_contains "refused without a task trailer" "$out" "plan-first gate: no 'Task: MIL-NNN#N' trailer"
assert_eq "no commit was made" "1" "$(project_git "$dir" rev-list --count HEAD)"
}
test_without_the_plan_gate_the_same_commit_is_allowed() {
setup_hosts
local dir="$WORK/project"
local_answers "$dir" y n
run_apply "$LOCAL_ANSWERS"$'y\n'
project_git "$dir" checkout -q -b work
mkdir -p "$dir/src"
printf 'x\n' >"$dir/src/x.txt"
project_git "$dir" add src/x.txt
project_git "$dir" commit -q -m "change"
assert_eq "commit made" "2" "$(project_git "$dir" rev-list --count HEAD)"
}
test_the_hooks_refuse_a_commit_on_main() {
setup_hosts
local dir="$WORK/project" out
local_answers "$dir" y n
run_apply "$LOCAL_ANSWERS"$'y\n'
printf 'x\n' >"$dir/x.txt"
project_git "$dir" add x.txt
out="$(project_git "$dir" commit -q -m "on main" 2>&1 || true)"
assert_contains "refused on main" "$out" "refusing to commit directly on 'main'"
}
test_an_existing_hooks_path_is_not_replaced_without_a_yes() {
setup_hosts
local dir="$WORK/project"
mkdir -p "$dir"
project_git "$dir" init -q
project_git "$dir" config core.hooksPath .githooks
local_answers "$dir" y y
# create now, use the directory, keep the hooks path
run_apply "$LOCAL_ANSWERS"$'y\ny\nn\n'
assert_status "run continues" 0 "$STATUS"
assert_contains "asked" "$ERR" "core.hooksPath is already '.githooks'. Replace it with framework/githooks"
assert_eq "hooks path kept" ".githooks" "$(project_git "$dir" config --local --get core.hooksPath)"
assert_contains "reported" "$OUT" "kept the existing hooks path '.githooks'"
assert_contains "the plan gate is not on without the hooks" "$ERR" "the plan gate is not enabled because the framework hooks were not installed"
# Answering yes replaces it.
run_apply "$LOCAL_ANSWERS"$'y\ny\ny\n'
assert_eq "hooks path replaced after a yes" "framework/githooks" "$(project_git "$dir" config --local --get core.hooksPath)"
}
test_a_global_hooks_path_is_reported_not_changed() {
setup_hosts
git config --file "$WORK/gitconfig" core.hooksPath /somewhere/global-hooks
local dir="$WORK/project"
local_answers "$dir" y n
run_apply "$LOCAL_ANSWERS"$'y\n'
assert_status "apply" 0 "$STATUS"
assert_contains "warns" "$ERR" "your global core.hooksPath is '/somewhere/global-hooks'"
assert_eq "the global setting is untouched" "/somewhere/global-hooks" "$(git config --file "$WORK/gitconfig" --get core.hooksPath)"
assert_eq "the project has its own" "framework/githooks" "$(project_git "$dir" config --local --get core.hooksPath)"
}
test_a_project_root_in_the_environment_cannot_redirect_the_framework_scripts() {
setup_hosts
local dir="$WORK/project"
local_answers "$dir" y n
PROJECT_ROOT="$WORK/elsewhere" run_apply "$LOCAL_ANSWERS"$'y\n'
assert_status "apply" 0 "$STATUS"
assert_file_missing "nothing installed where the environment pointed" "$WORK/elsewhere"
assert_file_exists "installed in the project" "$dir/.claude/skills/.framework-skills"
}
# ---------------------------------------------------------------- templates
test_templates_are_copied() {
setup_hosts
local dir="$WORK/project"
local_answers "$dir" y n
run_apply "$LOCAL_ANSWERS"$'y\n'
assert_status "apply" 0 "$STATUS"
assert_eq "AGENTS.md is the template" "$(cat "$dir/framework/templates/AGENTS-template.md")" "$(cat "$dir/AGENTS.md")"
assert_eq "registry is the template" "$(cat "$dir/framework/templates/artifact-registry-template.md")" "$(cat "$dir/docs/artifact-registry.md")"
assert_contains "reported" "$OUT" "Templates : created (copied AGENTS.md; copied docs/artifact-registry.md)"
}
test_existing_template_targets_are_replaced_only_after_a_yes() {
setup_hosts
local dir="$WORK/project"
mkdir -p "$dir/docs"
printf 'my agents file\n' >"$dir/AGENTS.md"
printf 'my registry\n' >"$dir/docs/artifact-registry.md"
local_answers "$dir" y n
# create now, use the directory, keep AGENTS.md, replace the registry
run_apply "$LOCAL_ANSWERS"$'y\ny\nn\ny\n'
assert_status "apply" 0 "$STATUS"
assert_contains "asked about AGENTS.md" "$ERR" "AGENTS.md already exists. Replace it with the framework template"
assert_eq "AGENTS.md kept" "my agents file" "$(cat "$dir/AGENTS.md")"
assert_eq "registry replaced after a yes" "$(cat "$dir/framework/templates/artifact-registry-template.md")" "$(cat "$dir/docs/artifact-registry.md")"
assert_contains "reported" "$OUT" "kept existing AGENTS.md; copied docs/artifact-registry.md"
}
# ----------------------------------------------------------- SSH and errors
test_without_ssh_the_run_stops_unless_the_framework_is_skipped() {
setup_hosts
write_ssh_stub 255
local dir="$WORK/project"
local_answers "$dir" y n
run_apply "$LOCAL_ANSWERS"$'y\nn\n'
assert_status "answered no" 1 "$STATUS"
assert_contains "explains" "$ERR" "SSH to Gitea (git.example.test port 10022) did not work, so the framework cannot be added"
assert_contains "says what to do" "$ERR" "stopped: set up SSH access to Gitea (see the README) and run again"
assert_not_contains "nothing created" "$(calls)" "POST"
assert_file_missing "no directory" "$dir"
}
test_without_ssh_the_framework_steps_are_skipped_after_a_yes() {
setup_hosts
write_ssh_stub 255
local dir="$WORK/project"
local_answers "$dir" y n
run_apply "$LOCAL_ANSWERS"$'y\ny\n'
assert_status "continue without the framework" 0 "$STATUS"
assert_eq "origin over HTTPS, no credential" "https://git.example.test/TirSystem/my-app.git" "$(project_git "$dir" config --get remote.origin.url)"
assert_eq "the license history arrived over HTTPS" "1" "$(project_git "$dir" rev-list --count HEAD)"
assert_eq "no token in any file" "" "$(files_with_secret "$dir")"
assert_eq "no temporary files left" "" "$(find "$WORK/tmp" -mindepth 1)"
assert_contains "framework skipped" "$OUT" "Framework : skipped (no SSH access to Gitea)"
assert_contains "skills and hooks skipped" "$OUT" "Skills and hooks : skipped (no SSH access to Gitea)"
assert_contains "templates skipped" "$OUT" "Templates : skipped (no SSH access to Gitea)"
assert_file_missing "no submodule" "$dir/.gitmodules"
assert_file_missing "no AGENTS.md" "$dir/AGENTS.md"
}
test_a_failed_submodule_gives_an_actionable_message() {
setup_hosts
find "$WORK/remote/TirSystem/SQA-QC-Framework.git" \( -type f -o -type l \) -delete
find "$WORK/remote/TirSystem/SQA-QC-Framework.git" -depth -type d -exec rmdir {} +
local dir="$WORK/project"
local_answers "$dir" y n
run_apply "$LOCAL_ANSWERS"$'y\n'
assert_status "no framework repository" 1 "$STATUS"
assert_contains "names the address" "$ERR" "git could not add the framework from $SSH_FRAMEWORK_URL"
assert_contains "says how to test the access" "$ERR" "ssh -p 10022 -T git@git.example.test"
assert_contains "step failed" "$OUT" "Framework : FAILED"
assert_contains "the rest not attempted" "$OUT" "Skills and hooks : not attempted"
assert_contains "the project itself exists" "$OUT" "Local project : created"
}
test_the_framework_repository_is_configurable() {
setup_hosts
mkdir -p "$WORK/remote/Other"
cp -R "$WORK/remote/TirSystem/SQA-QC-Framework.git" "$WORK/remote/Other/Framework.git"
printf 'FRAMEWORK_REPO=Other/Framework\n' >>"$WORK/config.env"
local dir="$WORK/project"
local_answers "$dir" y n
run_apply "$LOCAL_ANSWERS"$'y\n'
assert_status "apply" 0 "$STATUS"
assert_eq "submodule address" "ssh://git@git.example.test:10022/Other/Framework.git" "$(project_git "$dir" config -f .gitmodules --get submodule.framework.url)"
}
test_framework_repo_must_look_like_owner_and_name() {
local value
for value in "nope" "a/b/c" "../x" "a/.." "a b/c" "/x"; do
printf 'GITEA_URL=https://git.example.test\nFRAMEWORK_REPO=%s\n' "$value" >"$WORK/c.env"
run_lib "" "parse_env_file \"$WORK/c.env\" CONFIG_KEYS CONFIG
validate_config"
assert_status "FRAMEWORK_REPO=$value" 1 "$STATUS"
assert_contains "message" "$ERR" "FRAMEWORK_REPO"
done
}
# --------------------------------------------------------------- the plan
test_the_dry_run_plan_describes_the_local_steps_and_creates_nothing() {
setup_hosts
local dir="$WORK/project"
local_answers "$dir" y y
run_dry "$LOCAL_ANSWERS"
assert_status "dry run" 0 "$STATUS"
assert_contains "project" "$OUT" "Local project : create $dir (new directory), git on main, no commit"
assert_contains "framework" "$OUT" "Framework : add $SSH_FRAMEWORK_URL as a submodule"
assert_contains "skills and hooks" "$OUT" "Skills and hooks : install once; plan gate yes"
assert_contains "templates" "$OUT" "Templates : AGENTS.md and docs/artifact-registry.md (you are asked before a file is replaced)"
assert_file_missing "nothing created" "$dir"
}
test_the_plan_marks_an_existing_directory_and_missing_ssh() {
setup_hosts
local dir="$WORK/project"
mkdir -p "$dir"
printf 'x\n' >"$dir/a.txt"
write_ssh_stub 255
local_answers "$dir" y n
run_dry "$LOCAL_ANSWERS"
assert_contains "existing directory" "$OUT" "use the existing directory $dir, which has files (you will be asked)"
assert_contains "no SSH" "$OUT" "NOT possible without SSH to Gitea; you will be asked whether to go on without it"
assert_contains "HTTPS origin" "$OUT" "will use HTTPS (SSH test: failed"
}
test_a_project_path_that_is_a_file_is_refused_in_the_preflight() {
setup_hosts
local dir="$WORK/project"
printf 'x\n' >"$dir"
local_answers "$dir" y n
run_dry "$LOCAL_ANSWERS"
assert_status "path is a file" 1 "$STATUS"
assert_contains "message" "$ERR" "already exists and is not a directory"
}
# ------------------------------------------------------------------ helpers
test_remote_addresses_are_built_from_the_configuration() {
run_lib "" 'CONFIG[GITEA_URL]=https://git.example.test/sub
CONFIG[GITEA_SSH_PORT]=2222 CONFIG[FRAMEWORK_REPO]=Org/Fw CONFIG[GITHUB_WEB_URL]=https://github.com
PROJECT[gitea_owner]=TirSystem PROJECT[github_owner]=acme PROJECT[name]=my-app
STATE[is_ssh_ok]=1
origin_url; echo
STATE[is_ssh_ok]=0
origin_url; echo
github_remote_url; echo
framework_url; echo
gitea_host; echo'
assert_eq "addresses" $'ssh://git@git.example.test:2222/TirSystem/my-app.git\nhttps://git.example.test/sub/TirSystem/my-app.git\nhttps://github.com/acme/my-app.git\nssh://git@git.example.test:2222/Org/Fw.git\ngit.example.test' "$OUT"
}
test_the_https_fetch_hands_the_token_over_through_the_environment_only() {
# A stub git plays the part of the server asking for credentials: it runs
# the GIT_ASKPASS helper the way git does and records the answers.
local real_git
real_git="$(command -v git)"
write_stub git "
if [[ \$* == *'config --get remote.origin.url'* ]]; then echo https://git.example.test/TirSystem/my-app.git; exit 0; fi
if [[ \$* == *fetch* ]]; then
printf '%s\n' \"\$@\" >>\"\$STUB_DIR/git.args\"
\"\$GIT_ASKPASS\" 'Username for https://git.example.test: ' >>\"\$STUB_DIR/askpass.out\"
\"\$GIT_ASKPASS\" 'Password for https://git.example.test: ' >>\"\$STUB_DIR/askpass.out\"
exit 0
fi
exec '$real_git' \"\$@\""
run_lib "" "setup_temp_dir
STATE[gitea_login]=gitea-user
CREDENTIALS[GITEA_TOKEN]='$FAKE_GITEA_TOKEN'
fetch_origin '$WORK'
cleanup"
assert_status "fetch" 0 "$STATUS"
assert_eq "user name and token reach git" $'gitea-user\n'"$FAKE_GITEA_TOKEN" "$(cat "$WORK/askpass.out")"
assert_not_contains "token not on the git command line" "$(cat "$WORK/git.args")" "$FAKE_GITEA_TOKEN"
assert_eq "the helper is removed" "" "$(find "$WORK/tmp" -mindepth 1)"
}
test_a_failed_https_fetch_is_reported_to_the_caller() {
write_stub git "
if [[ \$* == *'config --get remote.origin.url'* ]]; then echo https://git.example.test/TirSystem/my-app.git; exit 0; fi
if [[ \$* == *fetch* ]]; then exit 128; fi
exit 0"
run_lib "" "setup_temp_dir
STATE[gitea_login]=gitea-user
CREDENTIALS[GITEA_TOKEN]='$FAKE_GITEA_TOKEN'
if fetch_origin '$WORK'; then echo ok; else echo failed; fi
cleanup"
assert_eq "failure passed on" "failed" "$OUT"
assert_eq "the helper is removed" "" "$(find "$WORK/tmp" -mindepth 1)"
}
+1 -1
View File
@@ -103,7 +103,7 @@ test_usage_errors() {
assert_contains "help shows exit codes" "$OUT" "Exit codes"
run_cli "" --version
assert_status "version" 0 "$STATUS"
assert_contains "version output" "$OUT" "RepoFoundry 0.2.0"
assert_contains "version output" "$OUT" "RepoFoundry 0.3.0"
}
test_warns_when_env_is_not_ignored_by_git() {