Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f34affa071 | ||
|
|
613a288dea | ||
|
|
0030334e5e |
@@ -1,35 +1,186 @@
|
|||||||
# RepoFoundry
|
# RepoFoundry
|
||||||
|
|
||||||
RepoFoundry (`src/create-project.sh`) sets up a new project: a Gitea
|
RepoFoundry (`src/create-project.sh`) sets up a new project in one run:
|
||||||
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.
|
|
||||||
|
|
||||||
> **Status: work in progress.** The script can validate its configuration,
|
- a **Gitea** repository (the source of truth),
|
||||||
> check both hosts and create the repositories and the mirror. Creating the
|
- optionally an empty **GitHub** repository that receives everything through a
|
||||||
> local project, and the full installation guide, come in a later phase
|
**push mirror from Gitea to GitHub**,
|
||||||
> (MIL-003). This file so far documents what is needed to run the host steps
|
- and a **local project** with credential-free remotes and the
|
||||||
> safely.
|
[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 end-to-end run
|
||||||
|
> on real GitHub and Gitea repositories, with organization owners on both, has
|
||||||
|
> passed (2026-10-05, review record RC-017). Creating a repository under your
|
||||||
|
> own Gitea account needs the `write:user` token scope (see
|
||||||
|
> [Token permissions](#token-permissions)); that path has not been completed
|
||||||
|
> yet.
|
||||||
|
|
||||||
|
## 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
|
```bash
|
||||||
cp config.env.example config.env # service addresses, not secret
|
git clone https://git.tirsystem.com/TirSystem-BashScript/RepoFoundry.git
|
||||||
cp .env.example .env # credentials: keep private
|
cd RepoFoundry
|
||||||
chmod 600 .env # Linux and macOS
|
src/create-project.sh --help
|
||||||
|
|
||||||
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
|
Nothing has to be installed system-wide: the script runs from the checkout and
|
||||||
tokens, the owners, the name, the license and SSH) and prints a plan. With
|
loads its own files from `src/lib/`.
|
||||||
`--apply` it prints the plan again and asks a final question before it creates
|
|
||||||
anything. Nothing is ever deleted by the script.
|
|
||||||
|
|
||||||
Choosing GitHub also applies the AGPL-3.0 license to the Gitea repository, so
|
## Configuration
|
||||||
that repository is not empty. Without GitHub the Gitea repository is created
|
|
||||||
empty and has no license.
|
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: set GITEA_URL (and GITEA_API_URL)
|
||||||
|
cp .env.example .env # credentials: keep private
|
||||||
|
chmod 600 .env # Linux and macOS
|
||||||
|
```
|
||||||
|
|
||||||
|
### `config.env` (service addresses)
|
||||||
|
|
||||||
|
| 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
|
## Token permissions
|
||||||
|
|
||||||
@@ -48,7 +199,7 @@ repositories for the chosen owner and to push to the new one.
|
|||||||
| Create a private repository | classic token with the `repo` scope | GitHub REST documentation, "Create a repository" |
|
| Create a private repository | classic token with the `repo` scope | GitHub REST documentation, "Create a repository" |
|
||||||
| Create a public repository only | classic token with `public_repo` is enough | same |
|
| Create a public repository only | classic token with `public_repo` is enough | same |
|
||||||
| Push from the Gitea mirror | covered by `repo` | |
|
| Push from the Gitea mirror | covered by `repo` | |
|
||||||
| Check that you belong to the organization owner | probably `read:org` | **Not confirmed**: the GitHub documentation names no scope for this call. If the script says you do not belong to an organization that you do belong to, add `read:org`. |
|
| Check that you belong to the organization owner | worked with a classic token that has `repo` and `admin:org` | `read:org` alone was **not tested**: the GitHub documentation names no scope for this call. If the script says you do not belong to an organization that you do belong to, add `read:org`. |
|
||||||
|
|
||||||
- **Organization owners:** you must be an active member who is allowed to
|
- **Organization owners:** you must be an active member who is allowed to
|
||||||
create repositories in the organization. Organizations that require SSO or
|
create repositories in the organization. Organizations that require SSO or
|
||||||
@@ -56,23 +207,80 @@ repositories for the chosen owner and to push to the new one.
|
|||||||
- **Fine-grained tokens:** the GitHub documentation lists no fine-grained
|
- **Fine-grained tokens:** the GitHub documentation lists no fine-grained
|
||||||
permission for creating a repository, and this has not been tested.
|
permission for creating a repository, and this has not been tested.
|
||||||
Use a classic token until it has been.
|
Use a classic token until it has been.
|
||||||
- **`GITHUB_USER`:** names the account the token belongs to. It is only a
|
- **`GITHUB_USER`:** if it differs from the account the token belongs to, the
|
||||||
default for the owner prompt; the repository may belong to an organization.
|
script warns and uses the account the token belongs to.
|
||||||
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`)
|
### Gitea token (`GITEA_TOKEN`)
|
||||||
|
|
||||||
| Need | Scope | Source |
|
| Need | Scope | Source |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Read the account the token belongs to | `read:user` | Gitea documentation |
|
| Read the account the token belongs to | `read:user` | Gitea documentation |
|
||||||
| Create repositories, manage the push mirror | `write:repository` | Gitea documentation |
|
| Create a repository **under your own account** | `write:user` | **Confirmed by a real server**: without it Gitea answers `required=[write:user]` |
|
||||||
| Look up an organization and your permissions in it | `read:organization` | Gitea documentation |
|
| Create a repository in an organization, manage its push mirror | `write:organization` and `write:repository` | worked with a token that has both, plus `read:user`; the minimum was not narrowed down |
|
||||||
| 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. |
|
| Look up an organization and your permissions in it | covered by the scopes above | worked in the end-to-end run |
|
||||||
|
|
||||||
A missing scope shows up as an HTTP 403 with the server's own message. The
|
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.
|
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 and the README Gitea adds) | 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
|
## Known limitations
|
||||||
|
|
||||||
- **The mirror password is stored on the Gitea server.** Gitea needs the
|
- **The mirror password is stored on the Gitea server.** Gitea needs the
|
||||||
@@ -81,25 +289,27 @@ script stops before it creates anything when a preflight check is refused.
|
|||||||
this, and revoke it if the Gitea server is ever in doubt.
|
this, and revoke it if the Gitea server is ever in doubt.
|
||||||
- **`sync_on_commit` may be ignored.** When a push mirror is created through
|
- **`sync_on_commit` may be ignored.** When a push mirror is created through
|
||||||
the API, some Gitea versions ignore `sync_on_commit` (upstream issue
|
the API, some Gitea versions ignore `sync_on_commit` (upstream issue
|
||||||
go-gitea/gitea#22990). The script reads the mirror back and warns if the
|
go-gitea/gitea#22990). On Gitea 1.27.3 it was applied: a branch pushed to
|
||||||
setting was not applied; the mirror then syncs on its interval
|
Gitea reached GitHub within seconds. The script reads the mirror back and
|
||||||
|
warns if the setting was not applied; the mirror then syncs on its interval
|
||||||
(`MIRROR_INTERVAL`, default 10 minutes). The first sync is requested right
|
(`MIRROR_INTERVAL`, default 10 minutes). The first sync is requested right
|
||||||
after the mirror is created.
|
after the mirror is created.
|
||||||
- **The server decides the shortest interval** and whether push mirrors are
|
- **The server decides the shortest interval** and whether push mirrors are
|
||||||
allowed at all. A refused mirror stops the run with the server's message;
|
allowed at all. A refused mirror stops the run with the server's message;
|
||||||
the repositories created so far are kept.
|
the repositories created so far are kept.
|
||||||
- **The license commit.** Gitea adds the license file when the repository is
|
- **The license commit.** Gitea adds the license file when the repository is
|
||||||
created with `auto_init`. The script sends no README, so the repository
|
created with `auto_init`, and on the real server it also adds a generated
|
||||||
should hold only `LICENSE`; this is checked in the MIL-003 end-to-end test.
|
`README.md`. Both are mirrored to GitHub and become the first commit of the
|
||||||
- **No rollback.** If a step fails, the script reports what exists and how to
|
local project. A Gitea repository that holds only these files counts as
|
||||||
continue. A repeated run offers to reuse a repository it created earlier
|
content this script created and is offered for reuse; a repository with
|
||||||
(empty, or in Gitea's case holding only the license). Delete what you do not
|
anything else counts as having content and is refused.
|
||||||
want in the web interface.
|
- **No rollback.** See [Error handling and recovery](#error-handling-and-recovery).
|
||||||
- **Mirror direction is Gitea to GitHub only.** Push to Gitea; GitHub is a
|
- **Mirror direction is Gitea to GitHub only.** Push to Gitea; GitHub is a
|
||||||
copy.
|
copy.
|
||||||
- **Requirements:** bash 4.4 or later, `git`, `curl` and `mktemp`; `jq` and
|
- **The framework needs SSH.** Without SSH access to Gitea the framework steps
|
||||||
`ssh` are optional. Without `jq` the script reads the few JSON fields it
|
can only be skipped.
|
||||||
needs with a simple built-in reader.
|
- **Tested on Windows (Git Bash) only so far.** Running the tests on Linux and
|
||||||
|
macOS is an open follow-up.
|
||||||
|
|
||||||
## Code layout
|
## Code layout
|
||||||
|
|
||||||
@@ -121,12 +331,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 |
|
| `api.sh` | GitHub and Gitea API calls and reporting a refused call |
|
||||||
| `prompts.sh` | interactive questions with validation |
|
| `prompts.sh` | interactive questions with validation |
|
||||||
| `project.sh` | the project details: asking for them and showing them |
|
| `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 |
|
| `preflight.sh` | read-only checks of both hosts |
|
||||||
| `steps.sh` | the outcome of each step and the final report |
|
| `steps.sh` | the outcome of each step and the final report |
|
||||||
| `plan.sh` | printing what the script is about to do |
|
| `plan.sh` | printing what the script is about to do |
|
||||||
| `repositories.sh` | creating the GitHub and Gitea repositories |
|
| `repositories.sh` | creating the GitHub and Gitea repositories |
|
||||||
| `mirror.sh` | the Gitea to GitHub push mirror |
|
| `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 |
|
| `apply.sh` | confirmations and the apply flow; the only code that changes anything |
|
||||||
| `cli.sh` | usage text and option parsing |
|
| `cli.sh` | usage text and option parsing |
|
||||||
|
|
||||||
@@ -138,5 +351,28 @@ when it is loaded.
|
|||||||
## Development
|
## Development
|
||||||
|
|
||||||
```bash
|
```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).
|
||||||
|
|||||||
+7
-2
@@ -17,10 +17,10 @@ GITHUB_API_URL=https://api.github.com
|
|||||||
GITHUB_WEB_URL=https://github.com
|
GITHUB_WEB_URL=https://github.com
|
||||||
|
|
||||||
# Gitea instance base URL. Repository links are derived from it.
|
# Gitea instance base URL. Repository links are derived from it.
|
||||||
GITEA_URL=https://git.tirsystem.com/
|
GITEA_URL=https://<your gitea instance>/
|
||||||
|
|
||||||
# Gitea REST API base URL. If you leave this out it is GITEA_URL + /api/v1.
|
# Gitea REST API base URL. If you leave this out it is GITEA_URL + /api/v1.
|
||||||
GITEA_API_URL=https://git.tirsystem.com/api/v1
|
GITEA_API_URL=https://<your gitea instance>/api/v1
|
||||||
|
|
||||||
# Optional. SSH port of the Gitea server, used for the SSH check and later
|
# Optional. SSH port of the Gitea server, used for the SSH check and later
|
||||||
# for the framework submodule. Default: 10022.
|
# for the framework submodule. Default: 10022.
|
||||||
@@ -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).
|
# Optional. How often Gitea pushes to GitHub, as a Go duration (10m0s, 8h0m0s).
|
||||||
# The server may enforce a minimum. Default: 10m0s.
|
# The server may enforce a minimum. Default: 10m0s.
|
||||||
#MIRROR_INTERVAL=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
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
## Version History
|
## Version History
|
||||||
| Date | Status | Author | Reviewer | Change | Commit |
|
| Date | Status | Author | Reviewer | Change | Commit |
|
||||||
| --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- |
|
||||||
| 2026-10-05 | Deprecated | Jens Tirsvad Nielsen | S02 | Initial version | [424f14f] |
|
|
||||||
| 2026-10-05 | Accepted | Jens Tirsvad Nielsen | S02 | Optional GitHub; choosing GitHub applies the AGPL license to the Gitea repository<br>Cited US-001.02<br>Purpose and criterion 1 reworded for optional GitHub<br>Target date accepted | [02875ae] |
|
| 2026-10-05 | Accepted | Jens Tirsvad Nielsen | S02 | Optional GitHub; choosing GitHub applies the AGPL license to the Gitea repository<br>Cited US-001.02<br>Purpose and criterion 1 reworded for optional GitHub<br>Target date accepted | [02875ae] |
|
||||||
|
| 2026-10-05 | Proposed | Jens Tirsvad Nielsen | S02 | Criterion 2 corrected after the live run: Gitea also adds a README.md with the license | [613a288] |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ Decide whether the script creates the Gitea repository and, if GitHub is chosen,
|
|||||||
| # | Criterion (objectively checkable) | Go | No-Go |
|
| # | Criterion (objectively checkable) | Go | No-Go |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| 1 | Repositories are created under the owner chosen at the prompt, for a user owner and for an organization owner, on each host used | Both verified | Any under the wrong owner |
|
| 1 | Repositories are created under the owner chosen at the prompt, for a user owner and for an organization owner, on each host used | Both verified | Any under the wrong owner |
|
||||||
| 2 | The GitHub repository is created empty. The Gitea repository holds only the AGPL license file when GitHub is chosen, otherwise it is empty. Neither has a generated README or `.gitignore` | Verified | Any other commit present |
|
| 2 | The GitHub repository is created empty. The Gitea repository holds only the AGPL license file and the `README.md` that Gitea generates for it when GitHub is chosen, otherwise it is empty. Neither has a `.gitignore` | Verified | Any other file present |
|
||||||
| 3 | When GitHub is chosen, a commit pushed to Gitea (including the license file) appears on GitHub; nothing flows the other way. When GitHub is not chosen, no GitHub call is made | Verified | Wrong direction, no sync, or a GitHub call without the choice |
|
| 3 | When GitHub is chosen, a commit pushed to Gitea (including the license file) appears on GitHub; nothing flows the other way. When GitHub is not chosen, no GitHub call is made | Verified | Wrong direction, no sync, or a GitHub call without the choice |
|
||||||
| 4 | Mirror credentials are never part of a remote URL, log or output | None found | Any found |
|
| 4 | Mirror credentials are never part of a remote URL, log or output | None found | Any found |
|
||||||
| 5 | With an invalid token on one host, the script stops before creating anything or reports exactly what was created and how to continue | Verified | Silent or misleading |
|
| 5 | With an invalid token on one host, the script stops before creating anything or reports exactly what was created and how to continue | Verified | Silent or misleading |
|
||||||
@@ -76,5 +76,5 @@ Decide whether the script creates the Gitea repository and, if GitHub is chosen,
|
|||||||
[US-001]: ../user-stories.md
|
[US-001]: ../user-stories.md
|
||||||
[UC-001]: ../uc-001/uc.md
|
[UC-001]: ../uc-001/uc.md
|
||||||
[MIL-001]: ./mil-001-foundation.md
|
[MIL-001]: ./mil-001-foundation.md
|
||||||
[424f14f]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/424f14f4f5577bb47fea41c8f3a655dca953e6d8
|
|
||||||
[02875ae]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/02875aee5f2953473924074eea0056eb31af6b7a
|
[02875ae]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/02875aee5f2953473924074eea0056eb31af6b7a
|
||||||
|
[613a288]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/613a288dead4c19c00dee6fbb60d46bc3edf8889
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# SQA Review Record: End-to-end test and final security review (MIL-003)
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
| Key | Value |
|
||||||
|
| --- | --- |
|
||||||
|
| ID | RC-017 |
|
||||||
|
| CrossReference | [MIL-003], [MIL-002], [RC-016] |
|
||||||
|
|
||||||
|
## Version History
|
||||||
|
| Date | Status | Author | Reviewer | Change | Commit |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| 2026-10-05 | Proposed | Jens Tirsvad Nielsen | S02 | Initial version | [613a288] |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Artifact Under Review
|
||||||
|
|
||||||
|
- Instance reviewed: the whole flow of `src/create-project.sh` (branch `mil-003-scaffold-and-release` plus the fixes below), run against real GitHub and Gitea repositories; this is task 6 (issue #20) of [MIL-003] and the live evidence for [MIL-002].
|
||||||
|
- Checklist used: the Go/No-Go criteria of [MIL-003] and the credential, ownership, mirror, submodule and API items named in its task 6. No QC checklist covers an end-to-end run; the code itself was reviewed in [RC-016].
|
||||||
|
- Review date: 2026-10-05
|
||||||
|
- Hosts: Gitea 1.27.3 at `git.tirsystem.com` (SSH on port 10022) and GitHub; real tokens from `.env` (a classic GitHub token with `repo` and `admin:org`; a Gitea token with `write:repository`, `write:organization`, `read:user` and other scopes, but not `write:user`).
|
||||||
|
|
||||||
|
## End-to-end runs
|
||||||
|
|
||||||
|
| Run | Owners | Result |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Dry run | `Tirsvad` on both hosts | All preflight checks passed; nothing created. |
|
||||||
|
| First `--apply` | `Tirsvad` on both hosts | Stopped before creating anything: **a defect** (see finding F1). |
|
||||||
|
| A, after the fix | user `Tirsvad` on both hosts | GitHub repository created. Gitea refused: `required=[write:user]`, which the token lacks. The script stopped, reported what existed (GitHub created, Gitea FAILED, the rest not attempted) and how to continue, and deleted nothing. |
|
||||||
|
| B | organization `TirSystem-BashScript` on both hosts, plan gate on | Everything created: both repositories, the mirror, the local project, the framework, skills, hooks, plan gate and templates. No warning. |
|
||||||
|
|
||||||
|
After run B the following was checked independently of the script's own report:
|
||||||
|
|
||||||
|
- **Gitea:** private, owner the organization, default branch `main`, contents `LICENSE` and `README.md`; push mirror to `https://github.com/TirSystem-BashScript/repofoundry-e2e-org.git`, interval `10m0s`, `sync_on_commit` true, `last_error` empty.
|
||||||
|
- **GitHub:** private, owner the organization, contents `LICENSE` and `README.md`, one commit `Initial commit` (arrived through the mirror).
|
||||||
|
- **Local project:** on `main` with that one commit as its whole history, tracking `origin`; `origin` is `ssh://git@git.tirsystem.com:10022/TirSystem-BashScript/repofoundry-e2e-org.git` and `github` is `https://github.com/TirSystem-BashScript/repofoundry-e2e-org.git`; the submodule `framework` comes from `ssh://git@git.tirsystem.com:10022/TirSystem/SQA-QC-Framework.git`; `core.hooksPath` is `framework/githooks` and `planGate.enabled` is true; skills are installed for both harnesses; `AGENTS.md` equals the framework template; no commit was made by the script.
|
||||||
|
- **Hooks and gate, for real:** a commit on `main` was refused ("refusing to commit directly on 'main'"); a `src/` change without a `Task:` trailer on a branch was refused ("plan-first gate"); a documentation-only commit on a branch was accepted.
|
||||||
|
- **Mirror direction, for real:** that commit was pushed to Gitea over SSH and the branch `work` appeared on GitHub within seconds, without a manual sync.
|
||||||
|
|
||||||
|
## Checklist Results (MIL-003 Go/No-Go)
|
||||||
|
|
||||||
|
| # | Criterion | Status | Evidence/Notes |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1 | `git remote -v` shows `origin` (Gitea) and, when GitHub was chosen, `github`, with no credentials in any URL; with GitHub chosen the local history contains the license commit | Pass | Run B: configured addresses above, credential-free; history is the Gitea license commit. |
|
||||||
|
| 2 | `framework` is a submodule of `ssh://git@git.tirsystem.com:10022/TirSystem/SQA-QC-Framework.git` and the install scripts have run once, in the documented order | Pass | Run B: `.gitmodules` holds that address; skills, then hooks, then templates; each once. |
|
||||||
|
| 3 | An existing directory, `AGENTS.md` or `docs/artifact-registry.md` is never overwritten without a yes | Pass | Verified by the automated tests with real git (existing directory, conflicting `LICENSE`, existing templates); not repeated on the real hosts. |
|
||||||
|
| 4 | With the plan gate enabled, a commit touching `src/` or `tests/` without a `Task: MIL-NNN#N` trailer is refused | Pass | Run B, for real. |
|
||||||
|
| 5 | An existing `core.hooksPath` is reported and not replaced without consent | Pass | Verified by the automated tests with real git (local and global setting); not repeated on the real hosts. |
|
||||||
|
| 6 | README covers installation, configuration, usage examples, security decisions, error handling and stakeholders, in clear English | N-A | The sections are written; the review by S02 has not happened yet (action item). |
|
||||||
|
| 7 | End-to-end run on disposable repositories passes and the final review records no open security finding | Fail | No open security finding, and the organization-owner run passes on both hosts. The user-owner run could not be completed on Gitea (token scope). |
|
||||||
|
| 8 | All acceptance criteria of US-001.03 in [US-001] are met | Pass | Run B: remotes without credentials, framework, skills, hooks, plan gate and templates in place; the "asks first" criterion by the automated tests. |
|
||||||
|
|
||||||
|
## Final security review
|
||||||
|
|
||||||
|
| Item | Result | Evidence |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Credential handling | No finding | Both tokens were searched for in every file of the new project, including the whole `.git` folder, and in all output of all runs: zero hits. Remote addresses and `.gitmodules` carry no credential. Tokens went to `curl` through a private configuration file and, for an HTTPS fetch, to git through `GIT_ASKPASS` and the environment (covered by tests; the live runs used SSH). |
|
||||||
|
| Repository ownership | No finding | Created under the owner chosen at the prompt on both hosts (organization in run B; GitHub user in run A). `GITHUB_USER` was only a default. |
|
||||||
|
| Mirror direction | No finding | Gitea is the source: a branch pushed to Gitea reached GitHub on its own. Nothing was pushed from GitHub; that direction was not tested. |
|
||||||
|
| Submodule setup | No finding | Added over SSH on port 10022 from the configured framework repository; the SSH test and the host key check passed. |
|
||||||
|
| API limitations | Findings F2 to F4 | `sync_on_commit` was applied on Gitea 1.27.3 (the upstream bug did not occur). Token scopes and the README Gitea adds are covered below. |
|
||||||
|
| Residual risks | Accepted, documented | The mirror password (the GitHub token) is stored by the Gitea server. The tokens used here are broad (for example `admin:org` on GitHub); tokens limited to what the script needs would reduce the damage of a leak. |
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
| # | Finding | Severity | Status |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| F1 | The real `ssh` used for the SSH test reads standard input and swallowed the answers meant for later prompts, so a run that was piped or pasted stopped before creating anything. The test stub did not read stdin, so no test could see it. | Defect (no data lost) | Fixed: stdin is closed for `ssh`, `git`, `curl` and the framework scripts; the test stub now reads stdin like the real tool; a regression test fails without the fix. |
|
||||||
|
| F2 | Gitea adds a generated `README.md` next to the `LICENSE`. The script, the README and MIL-002 criterion 2 assumed only the license. A repository this script created was therefore counted as "has content" and could not be reused after a partial failure. | Defect | Fixed: `LICENSE` and `LICENSE` + `README.md` count as content created by the script; any other file still counts as content; tests added; README corrected. MIL-002 criterion 2 corrected (new `Proposed` version row, to be accepted). |
|
||||||
|
| F3 | Creating a repository under one's own Gitea account needs the `write:user` scope, not `write:repository`. | Documentation | Fixed in the README, marked as confirmed by the server. |
|
||||||
|
| F4 | Documentation had marked two scopes "not confirmed". Result: `write:user` is needed for user-owned Gitea repositories (F3); organization-owned repositories worked with `write:organization`, `write:repository` and `read:user`, and the minimum was not narrowed down; the GitHub membership check worked with `repo` and `admin:org`, `read:org` alone was not tested. | Documentation | README updated with what was observed. |
|
||||||
|
|
||||||
|
## Files created and external prerequisites
|
||||||
|
|
||||||
|
The script creates, in the new project: `.git`, `.gitmodules`, `framework/` (submodule), `.agents/skills/` and `.claude/skills/`, `AGENTS.md`, `docs/artifact-registry.md`, and (through the Gitea history) `LICENSE` and `README.md`. It never deletes anything.
|
||||||
|
|
||||||
|
External prerequisites: bash 4.4 or later, `git`, `curl`, `mktemp`; optional `jq` and `ssh`. An SSH key in the Gitea account and a known host key for `git.tirsystem.com` port 10022 (the script refuses unknown host keys). A GitHub token that can create repositories and push (classic `repo`), only when GitHub is chosen. A Gitea token with `write:repository`, `write:organization` and `read:user`, plus `write:user` for a repository under one's own account. Push mirrors must be enabled on the Gitea server.
|
||||||
|
|
||||||
|
## Disposable resources left in place (nothing was deleted)
|
||||||
|
|
||||||
|
- Gitea: `TirSystem-BashScript/repofoundry-e2e-org` (private; branches `main` and `work`).
|
||||||
|
- GitHub: `TirSystem-BashScript/repofoundry-e2e-org` (private; branches `main` and `work`).
|
||||||
|
- GitHub: `Tirsvad/repofoundry-e2e-user` (private, empty; created by run A).
|
||||||
|
- A local temporary directory with the run output and the new project.
|
||||||
|
|
||||||
|
## Overall Verdict
|
||||||
|
|
||||||
|
Go-with-conditions — No open security finding, the organization-owner flow works end to end on both hosts, and the two defects the live run found are fixed with regression tests. Criterion 6 awaits the README review, and criterion 7 is not complete because the user-owner run could not finish on Gitea without `write:user`. Author and reviewer are the same person for now (S01 and S02 are both held by the Maintainer), so the framework independence rule is not met; re-review when a second person takes S02.
|
||||||
|
|
||||||
|
## Action Items
|
||||||
|
|
||||||
|
| Action | Owner | Due |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Create a Gitea token that also has `write:user` and repeat run A (the empty GitHub repository `Tirsvad/repofoundry-e2e-user` is offered for reuse) to complete criterion 7 | S01 | 2026-10-16 |
|
||||||
|
| Review the README against criterion 6 | S02 | 2026-10-12 |
|
||||||
|
| Accept the corrected criterion 2 of [MIL-002] (version row `Proposed`) | S02 | 2026-10-12 |
|
||||||
|
| Delete the disposable repositories listed above in the web interfaces | S01 | 2026-10-12 |
|
||||||
|
| Run `tests/run-tests.sh` on Linux and macOS (carried over from [RC-016]) | S02 | 2026-10-30 |
|
||||||
|
| Consider tokens limited to what the script needs, for the Gitea and GitHub accounts used with it | S02 | 2026-10-30 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[MIL-003]: ../../milestones/mil-003-scaffold-and-release.md
|
||||||
|
[MIL-002]: ../../milestones/mil-002-repositories-and-mirror.md
|
||||||
|
[RC-016]: ./rc-016-create-project-sh.md
|
||||||
|
[US-001]: ../../user-stories.md
|
||||||
|
[613a288]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/613a288dead4c19c00dee6fbb60d46bc3edf8889
|
||||||
+25
-13
@@ -4,12 +4,14 @@
|
|||||||
# Purpose
|
# Purpose
|
||||||
# RepoFoundry creates a Gitea repository, optionally an empty GitHub
|
# RepoFoundry creates a Gitea repository, optionally an empty GitHub
|
||||||
# repository with a Gitea -> GitHub push mirror, and a local project with
|
# repository with a Gitea -> GitHub push mirror, and a local project with
|
||||||
# the SQA-QC-Framework. This version (MIL-002) validates the configuration
|
# the SQA-QC-Framework. It validates the configuration and credentials,
|
||||||
# and credentials, asks for the project details, checks both hosts with
|
# asks for the project details, checks both hosts with read-only requests
|
||||||
# read-only requests (tokens, owners, names, licence, SSH) and, with
|
# (tokens, owners, names, license, SSH) and, with --apply, creates the
|
||||||
# --apply, creates the repositories and the mirror. Choosing GitHub also
|
# repositories and the mirror, then the local project: its directory, git
|
||||||
# applies the AGPL-3.0 license to the Gitea repository. The local project
|
# repository, remotes (no credential in any address), the framework as a
|
||||||
# is not created yet.
|
# 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
|
# Dry run by default
|
||||||
# Without --apply the script only reads from GitHub and Gitea (GET
|
# Without --apply the script only reads from GitHub and Gitea (GET
|
||||||
@@ -31,8 +33,9 @@
|
|||||||
#
|
#
|
||||||
# Files (parsed, never sourced)
|
# Files (parsed, never sourced)
|
||||||
# config.env GITHUB_API_URL, GITHUB_WEB_URL, GITEA_URL, GITEA_API_URL and
|
# config.env GITHUB_API_URL, GITHUB_WEB_URL, GITEA_URL, GITEA_API_URL and
|
||||||
# the optional GITEA_SSH_PORT (default 10022) and
|
# the optional GITEA_SSH_PORT (default 10022), MIRROR_INTERVAL
|
||||||
# MIRROR_INTERVAL (default 10m0s)
|
# (default 10m0s) and FRAMEWORK_REPO (default
|
||||||
|
# TirSystem/SQA-QC-Framework, the submodule's OWNER/NAME)
|
||||||
# .env GITHUB_PAT, GITHUB_USER, GITEA_TOKEN
|
# .env GITHUB_PAT, GITHUB_USER, GITEA_TOKEN
|
||||||
#
|
#
|
||||||
# Environment
|
# Environment
|
||||||
@@ -44,13 +47,15 @@
|
|||||||
# Requires
|
# Requires
|
||||||
# bash 4.4 or later, git, curl, mktemp; jq and ssh are optional (jq is used
|
# 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).
|
# 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).
|
# stat (GNU "stat -c" or BSD "stat -f"; only used outside Windows).
|
||||||
#
|
#
|
||||||
# Implements
|
# Implements
|
||||||
# MIL-001 tasks 1 to 6 and MIL-002 tasks 1 to 5 (issues #3 to #13), user
|
# MIL-001 tasks 1 to 6, MIL-002 tasks 1 to 5 and MIL-003 tasks 1 to 4
|
||||||
# stories US-001.01 and US-001.02, UC-001 steps 1 to 7; see docs/. Deviation
|
# (issues #3 to #13 and #15 to #18), user stories US-001.01 to US-001.03,
|
||||||
# from the request: its second GITEA_URL key is named GITEA_API_URL.
|
# UC-001 steps 1 to 10; see docs/. Deviation from the request: its second
|
||||||
|
# GITEA_URL key is named GITEA_API_URL.
|
||||||
#
|
#
|
||||||
# Tracing
|
# Tracing
|
||||||
# set -x is switched off while the script runs, because a trace would print
|
# 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
|
# 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,
|
# each file): constants, output, temp, util, validate, config, tools, json,
|
||||||
# http, api, prompts, project, hosts, preflight, steps, plan, repositories,
|
# 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
|
# Exit codes
|
||||||
# 0 success (or a dry run, or a "no" at the final question), 1 a failed
|
# 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"
|
source "$SCRIPT_DIR/lib/repositories.sh"
|
||||||
# shellcheck source=lib/mirror.sh
|
# shellcheck source=lib/mirror.sh
|
||||||
source "$SCRIPT_DIR/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
|
# shellcheck source=lib/apply.sh
|
||||||
source "$SCRIPT_DIR/lib/apply.sh"
|
source "$SCRIPT_DIR/lib/apply.sh"
|
||||||
# shellcheck source=lib/cli.sh
|
# shellcheck source=lib/cli.sh
|
||||||
|
|||||||
+22
-1
@@ -4,7 +4,7 @@
|
|||||||
#
|
#
|
||||||
# Part of create-project.sh: sourced by it, never run on its own.
|
# 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() {
|
create_all() {
|
||||||
IS_CREATION_STARTED=1
|
IS_CREATION_STARTED=1
|
||||||
@@ -15,6 +15,25 @@ create_all() {
|
|||||||
if ((PROJECT[has_github])); then
|
if ((PROJECT[has_github])); then
|
||||||
configure_mirror
|
configure_mirror
|
||||||
fi
|
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() {
|
confirm_reuse() {
|
||||||
@@ -42,5 +61,7 @@ apply_plan() {
|
|||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
confirm_reuse
|
confirm_reuse
|
||||||
|
confirm_local_directory
|
||||||
|
confirm_framework_access
|
||||||
create_all
|
create_all
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,6 +100,9 @@ validate_config() {
|
|||||||
CONFIG[MIRROR_INTERVAL]="${CONFIG[MIRROR_INTERVAL]:-$DEFAULT_MIRROR_INTERVAL}"
|
CONFIG[MIRROR_INTERVAL]="${CONFIG[MIRROR_INTERVAL]:-$DEFAULT_MIRROR_INTERVAL}"
|
||||||
is_valid_interval "${CONFIG[MIRROR_INTERVAL]}" ||
|
is_valid_interval "${CONFIG[MIRROR_INTERVAL]}" ||
|
||||||
die "MIRROR_INTERVAL in $CONFIG_FILE must look like 10m0s or 8h0m0s"
|
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() {
|
validate_credentials() {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
# shellcheck disable=SC2034 # read and written by the other library files
|
# shellcheck disable=SC2034 # read and written by the other library files
|
||||||
|
|
||||||
readonly PROJECT_NAME="${REPOFOUNDRY_NAME:-RepoFoundry}"
|
readonly PROJECT_NAME="${REPOFOUNDRY_NAME:-RepoFoundry}"
|
||||||
readonly VERSION="0.2.0"
|
readonly VERSION="0.3.0"
|
||||||
readonly EXIT_FAILURE=1
|
readonly EXIT_FAILURE=1
|
||||||
readonly EXIT_USAGE=2
|
readonly EXIT_USAGE=2
|
||||||
readonly MAX_VALUE_LENGTH=2048
|
readonly MAX_VALUE_LENGTH=2048
|
||||||
@@ -16,12 +16,14 @@ readonly MAX_DESCRIPTION_LENGTH=350
|
|||||||
readonly HTTP_TIMEOUT_SECONDS=30
|
readonly HTTP_TIMEOUT_SECONDS=30
|
||||||
readonly DEFAULT_MIRROR_INTERVAL="10m0s"
|
readonly DEFAULT_MIRROR_INTERVAL="10m0s"
|
||||||
readonly DEFAULT_SSH_PORT=10022
|
readonly DEFAULT_SSH_PORT=10022
|
||||||
|
readonly DEFAULT_FRAMEWORK_REPO="TirSystem/SQA-QC-Framework"
|
||||||
readonly AGPL_LICENSE_KEY="AGPL-3.0"
|
readonly AGPL_LICENSE_KEY="AGPL-3.0"
|
||||||
readonly DEFAULT_BRANCH="main"
|
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)
|
# shellcheck disable=SC2034 # read through namerefs (parse_env_file)
|
||||||
readonly CONFIG_KEYS=(GITHUB_API_URL GITHUB_WEB_URL GITEA_URL GITEA_API_URL
|
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)
|
readonly CREDENTIAL_KEYS=(GITHUB_PAT GITHUB_USER GITEA_TOKEN)
|
||||||
|
|
||||||
CONFIG_FILE="$PROJECT_ROOT/config.env"
|
CONFIG_FILE="$PROJECT_ROOT/config.env"
|
||||||
|
|||||||
@@ -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" "$@" </dev/null) >"$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)"
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# 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 and stdin
|
||||||
|
# is closed (git must not eat the answers meant for later prompts), 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" "$@" </dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
# 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
@@ -4,7 +4,7 @@
|
|||||||
#
|
#
|
||||||
# Part of create-project.sh: sourced by it, never run on its own.
|
# 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 HOST: succeed if the existing repository on HOST will be reused.
|
||||||
is_reused() {
|
is_reused() {
|
||||||
@@ -26,3 +26,40 @@ repo_url() {
|
|||||||
printf '%s/%s/%s' "${CONFIG[GITHUB_WEB_URL]}" "${PROJECT[github_owner]}" "${PROJECT[name]}"
|
printf '%s/%s/%s' "${CONFIG[GITHUB_WEB_URL]}" "${PROJECT[github_owner]}" "${PROJECT[name]}"
|
||||||
fi
|
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]}"
|
||||||
|
}
|
||||||
|
|||||||
+1
-1
@@ -71,7 +71,7 @@ http_request() {
|
|||||||
# that never contains the request.
|
# that never contains the request.
|
||||||
HTTP_STATUS="$(curl --silent --max-time "$HTTP_TIMEOUT_SECONDS" \
|
HTTP_STATUS="$(curl --silent --max-time "$HTTP_TIMEOUT_SECONDS" \
|
||||||
--connect-timeout 10 --output "$out_file" --write-out '%{http_code}' \
|
--connect-timeout 10 --output "$out_file" --write-out '%{http_code}' \
|
||||||
--config "$config_file" "${data_args[@]}" 2>/dev/null)" || curl_status=$?
|
--config "$config_file" "${data_args[@]}" </dev/null 2>/dev/null)" || curl_status=$?
|
||||||
# The configuration file holds the token and the body may hold another one
|
# The configuration file holds the token and the body may hold another one
|
||||||
# (the mirror password): remove both now instead of at exit.
|
# (the mirror password): remove both now instead of at exit.
|
||||||
rm -f -- "$config_file" ${body_file:+"$body_file"}
|
rm -f -- "$config_file" ${body_file:+"$body_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
@@ -4,7 +4,17 @@
|
|||||||
#
|
#
|
||||||
# Part of create-project.sh: sourced by it, never run on its own.
|
# 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() {
|
print_plan() {
|
||||||
local gitea_action github_action origin_note
|
local gitea_action github_action origin_note
|
||||||
@@ -35,5 +45,13 @@ print_plan() {
|
|||||||
else
|
else
|
||||||
origin_note="HTTPS (SSH test: ${STATE[ssh_note]})"
|
origin_note="HTTPS (SSH test: ${STATE[ssh_note]})"
|
||||||
fi
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-9
@@ -28,7 +28,8 @@ check_gitea_license() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# inspect_repository HOST: record in STATE[HOST_repo] whether the repository
|
# inspect_repository HOST: record in STATE[HOST_repo] whether the repository
|
||||||
# is free (does not exist), empty, license_only or not_empty.
|
# is free (does not exist), empty, initial_only (just the LICENSE and the
|
||||||
|
# README.md Gitea adds) or not_empty.
|
||||||
inspect_repository() {
|
inspect_repository() {
|
||||||
local host="$1" owner name names
|
local host="$1" owner name names
|
||||||
owner="$(repo_owner "$host")"
|
owner="$(repo_owner "$host")"
|
||||||
@@ -45,10 +46,13 @@ inspect_repository() {
|
|||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
expect_status "cannot read the contents of the $(host_label "$host") repository $owner/$name" 200
|
expect_status "cannot read the contents of the $(host_label "$host") repository $owner/$name" 200
|
||||||
names="$(json_values "$HTTP_BODY_FILE" name)"
|
# Gitea adds a README.md of its own next to the LICENSE when it creates a
|
||||||
case "$names" in
|
# repository with a license (seen on a real server), so both count as the
|
||||||
|
# content this script creates.
|
||||||
|
names="$(json_values "$HTTP_BODY_FILE" name | sort | tr '\n' ' ')"
|
||||||
|
case "${names% }" in
|
||||||
"") STATE[${host}_repo]="empty" ;;
|
"") STATE[${host}_repo]="empty" ;;
|
||||||
LICENSE) STATE[${host}_repo]="license_only" ;;
|
"LICENSE" | "LICENSE README.md") STATE[${host}_repo]="initial_only" ;;
|
||||||
*) STATE[${host}_repo]="not_empty" ;;
|
*) STATE[${host}_repo]="not_empty" ;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
@@ -108,9 +112,7 @@ preflight_github() {
|
|||||||
# or without access it is simply "not passed"; it never stops the run.
|
# or without access it is simply "not passed"; it never stops the run.
|
||||||
test_gitea_ssh() {
|
test_gitea_ssh() {
|
||||||
local host port output status=0
|
local host port output status=0
|
||||||
host="${CONFIG[GITEA_URL]#https://}"
|
host="$(gitea_host)"
|
||||||
host="${host%%/*}"
|
|
||||||
host="${host%%:*}"
|
|
||||||
port="${CONFIG[GITEA_SSH_PORT]}"
|
port="${CONFIG[GITEA_SSH_PORT]}"
|
||||||
STATE[is_ssh_ok]=0
|
STATE[is_ssh_ok]=0
|
||||||
STATE[ssh_note]="failed (check your SSH key and that $host:$port is reachable)"
|
STATE[ssh_note]="failed (check your SSH key and that $host:$port is reachable)"
|
||||||
@@ -119,7 +121,7 @@ test_gitea_ssh() {
|
|||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
output="$(ssh -p "$port" -o BatchMode=yes -o ConnectTimeout=5 \
|
output="$(ssh -p "$port" -o BatchMode=yes -o ConnectTimeout=5 \
|
||||||
-o StrictHostKeyChecking=yes -T "git@$host" 2>&1)" || status=$?
|
-o StrictHostKeyChecking=yes -T "git@$host" </dev/null 2>&1)" || status=$?
|
||||||
if ((status == 0)) || [[ $output == *"successfully authenticated"* ]]; then
|
if ((status == 0)) || [[ $output == *"successfully authenticated"* ]]; then
|
||||||
STATE[is_ssh_ok]=1
|
STATE[is_ssh_ok]=1
|
||||||
STATE[ssh_note]="passed"
|
STATE[ssh_note]="passed"
|
||||||
@@ -140,7 +142,7 @@ decide_existing_repositories() {
|
|||||||
case "$state" in
|
case "$state" in
|
||||||
free) ;;
|
free) ;;
|
||||||
empty) STATE[reuse_$host]=1 ;;
|
empty) STATE[reuse_$host]=1 ;;
|
||||||
license_only)
|
initial_only)
|
||||||
if [[ $host == gitea ]] && ((PROJECT[has_github])); then
|
if [[ $host == gitea ]] && ((PROJECT[has_github])); then
|
||||||
STATE[reuse_$host]=1
|
STATE[reuse_$host]=1
|
||||||
else
|
else
|
||||||
@@ -163,5 +165,6 @@ run_preflight() {
|
|||||||
fi
|
fi
|
||||||
test_gitea_ssh
|
test_gitea_ssh
|
||||||
decide_existing_repositories
|
decide_existing_repositories
|
||||||
|
inspect_local_directory
|
||||||
say "All checks passed."
|
say "All checks passed."
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-3
@@ -42,10 +42,11 @@ report_outcome() {
|
|||||||
say "$(printf ' %-18s: %s' "$label" "${STEP_STATUS[$label]}${STEP_DETAIL[$label]:+ ${STEP_DETAIL[$label]}}")"
|
say "$(printf ' %-18s: %s' "$label" "${STEP_STATUS[$label]}${STEP_DETAIL[$label]:+ ${STEP_DETAIL[$label]}}")"
|
||||||
done
|
done
|
||||||
if ((code == 0)); then
|
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
|
else
|
||||||
say "To continue: fix the problem named above and run the same command again with --apply."
|
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 "A repository this run created is still empty (or holds only the license and the README Gitea adds), 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
|
fi
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-1
@@ -4,7 +4,7 @@
|
|||||||
#
|
#
|
||||||
# Part of create-project.sh: sourced by it, never run on its own.
|
# 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() {
|
is_valid_repo_name() {
|
||||||
local name="$1"
|
local name="$1"
|
||||||
@@ -49,6 +49,11 @@ is_valid_token() {
|
|||||||
[[ $1 =~ ^[A-Za-z0-9_.~+/=-]{8,255}$ ]]
|
[[ $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() {
|
is_valid_port() {
|
||||||
[[ $1 =~ ^[0-9]{1,5}$ ]] && ((10#$1 >= 1 && 10#$1 <= 65535))
|
[[ $1 =~ ^[0-9]{1,5}$ ]] && ((10#$1 >= 1 && 10#$1 <= 65535))
|
||||||
}
|
}
|
||||||
|
|||||||
+68
-6
@@ -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_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'
|
readonly ANSWERS_GITEA_ONLY=$'my-app\n\n\nTirSystem\nn\n\nn\n'
|
||||||
|
|
||||||
|
SHARED_REMOTES=""
|
||||||
TESTS_RUN=0
|
TESTS_RUN=0
|
||||||
TESTS_FAILED=0
|
TESTS_FAILED=0
|
||||||
CURRENT_TEST=""
|
CURRENT_TEST=""
|
||||||
@@ -91,6 +92,61 @@ assert_file_missing() {
|
|||||||
new_workdir() {
|
new_workdir() {
|
||||||
WORK="$(mktemp -d "${TMPDIR:-/tmp}/repofoundry-test.XXXXXX")"
|
WORK="$(mktemp -d "${TMPDIR:-/tmp}/repofoundry-test.XXXXXX")"
|
||||||
mkdir -p "$WORK/bin" "$WORK/tmp"
|
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
|
# remove_workdir: delete the work directory without a recursive rm: files
|
||||||
@@ -188,6 +244,9 @@ STUB
|
|||||||
write_ssh_stub() {
|
write_ssh_stub() {
|
||||||
cat >"$WORK/bin/ssh" <<STUB
|
cat >"$WORK/bin/ssh" <<STUB
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
# The real ssh reads standard input until it ends; so does this stub. Whatever
|
||||||
|
# is left on the caller's stdin (the answers to later prompts) is lost to it.
|
||||||
|
cat >/dev/null
|
||||||
printf '%s\n' "\$@" >>"\$STUB_DIR/ssh.args"
|
printf '%s\n' "\$@" >>"\$STUB_DIR/ssh.args"
|
||||||
if [[ ${1:-0} == 0 ]]; then
|
if [[ ${1:-0} == 0 ]]; then
|
||||||
echo "Hi there, gitea-user! You've successfully authenticated, but Gitea does not provide shell access."
|
echo "Hi there, gitea-user! You've successfully authenticated, but Gitea does not provide shell access."
|
||||||
@@ -245,6 +304,7 @@ setup_hosts() {
|
|||||||
write_curl_stub
|
write_curl_stub
|
||||||
write_ssh_stub 0
|
write_ssh_stub 0
|
||||||
write_happy_routes
|
write_happy_routes
|
||||||
|
setup_local_remotes
|
||||||
}
|
}
|
||||||
|
|
||||||
# calls: the "METHOD URL" lines the stub curl received (empty if none).
|
# calls: the "METHOD URL" lines the stub curl received (empty if none).
|
||||||
@@ -260,9 +320,11 @@ run_cli() {
|
|||||||
local input="$1"
|
local input="$1"
|
||||||
shift
|
shift
|
||||||
STATUS=0
|
STATUS=0
|
||||||
PATH="$WORK/bin:$PATH" STUB_DIR="$WORK" TMPDIR="$WORK/tmp" \
|
# Run inside the work directory: a relative project directory such as
|
||||||
REPOFOUNDRY_SYNC_WAIT=0 \
|
# ./my-app is then created there, never in the repository.
|
||||||
"$BASH" "$SCRIPT" "$@" <<<"$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" "$SCRIPT" "$@" <<<"$input" >"$WORK/out.txt" 2>"$WORK/err.txt") ||
|
||||||
STATUS=$?
|
STATUS=$?
|
||||||
OUT="$(cat "$WORK/out.txt")"
|
OUT="$(cat "$WORK/out.txt")"
|
||||||
ERR="$(cat "$WORK/err.txt")"
|
ERR="$(cat "$WORK/err.txt")"
|
||||||
@@ -277,9 +339,9 @@ run_lib() {
|
|||||||
printf '#!/usr/bin/env bash\nsource "%s"\n' "$SCRIPT"
|
printf '#!/usr/bin/env bash\nsource "%s"\n' "$SCRIPT"
|
||||||
printf '%s\n' "$2"
|
printf '%s\n' "$2"
|
||||||
} >"$WORK/snippet.sh"
|
} >"$WORK/snippet.sh"
|
||||||
PATH="$WORK/bin:$PATH" STUB_DIR="$WORK" TMPDIR="$WORK/tmp" \
|
(cd "$WORK" && PATH="$WORK/bin:$PATH" STUB_DIR="$WORK" TMPDIR="$WORK/tmp" \
|
||||||
REPOFOUNDRY_SYNC_WAIT=0 \
|
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" ||
|
"$BASH" "$WORK/snippet.sh" <<<"$input" >"$WORK/out.txt" 2>"$WORK/err.txt") ||
|
||||||
STATUS=$?
|
STATUS=$?
|
||||||
OUT="$(cat "$WORK/out.txt")"
|
OUT="$(cat "$WORK/out.txt")"
|
||||||
ERR="$(cat "$WORK/err.txt")"
|
ERR="$(cat "$WORK/err.txt")"
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ for test_file in "$TEST_DIR"/test-*.sh; do
|
|||||||
done < <(declare -F | awk '$3 ~ /^test_/ {print $3}')
|
done < <(declare -F | awk '$3 ~ /^test_/ {print $3}')
|
||||||
done
|
done
|
||||||
|
|
||||||
|
remove_shared_remotes
|
||||||
printf '\n%d checks, %d failed, %d static check(s) failed\n' \
|
printf '\n%d checks, %d failed, %d static check(s) failed\n' \
|
||||||
"$TESTS_RUN" "$TESTS_FAILED" "$failed_checks"
|
"$TESTS_RUN" "$TESTS_FAILED" "$failed_checks"
|
||||||
if ((TESTS_FAILED > 0 || failed_checks > 0)); then
|
if ((TESTS_FAILED > 0 || failed_checks > 0)); then
|
||||||
|
|||||||
+12
-1
@@ -158,10 +158,21 @@ test_example_files_hold_placeholders_only() {
|
|||||||
fail ".env.example has a value for ${line%%=*}; it must be empty"
|
fail ".env.example has a value for ${line%%=*}; it must be empty"
|
||||||
fi
|
fi
|
||||||
done < <(grep -E '^[A-Z_]+=' "$REPO_ROOT/.env.example")
|
done < <(grep -E '^[A-Z_]+=' "$REPO_ROOT/.env.example")
|
||||||
|
# The example holds a placeholder for the Gitea address, so it must be
|
||||||
|
# edited before use: as it is, it is refused with a clear message...
|
||||||
run_lib "" "parse_env_file \"$REPO_ROOT/config.env.example\" CONFIG_KEYS CONFIG
|
run_lib "" "parse_env_file \"$REPO_ROOT/config.env.example\" CONFIG_KEYS CONFIG
|
||||||
validate_config
|
validate_config
|
||||||
echo parsed"
|
echo parsed"
|
||||||
assert_contains "config.env.example is valid" "$OUT" "parsed"
|
assert_status "placeholder address is refused" 1 "$STATUS"
|
||||||
|
assert_contains "names the key" "$ERR" "GITEA_URL"
|
||||||
|
# ...and with a real address in its place the rest of the file is valid.
|
||||||
|
sed -e 's|^GITEA_URL=.*|GITEA_URL=https://git.example.test/|' \
|
||||||
|
-e 's|^GITEA_API_URL=.*|GITEA_API_URL=https://git.example.test/api/v1|' \
|
||||||
|
"$REPO_ROOT/config.env.example" >"$WORK/example.env"
|
||||||
|
run_lib "" "parse_env_file \"$WORK/example.env\" CONFIG_KEYS CONFIG
|
||||||
|
validate_config
|
||||||
|
echo parsed"
|
||||||
|
assert_contains "config.env.example is valid once the address is set" "$OUT" "parsed"
|
||||||
run_lib "" "parse_env_file \"$REPO_ROOT/.env.example\" CREDENTIAL_KEYS CREDENTIALS
|
run_lib "" "parse_env_file \"$REPO_ROOT/.env.example\" CREDENTIAL_KEYS CREDENTIALS
|
||||||
echo parsed"
|
echo parsed"
|
||||||
assert_contains ".env.example parses" "$OUT" "parsed"
|
assert_contains ".env.example parses" "$OUT" "parsed"
|
||||||
|
|||||||
@@ -264,6 +264,9 @@ test_apply_declined_creates_nothing() {
|
|||||||
|
|
||||||
test_apply_for_user_owners_uses_the_user_endpoints() {
|
test_apply_for_user_owners_uses_the_user_endpoints() {
|
||||||
setup_hosts
|
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'
|
write_routes <<'ROUTES'
|
||||||
GET|/api/v1/user|200|{"login":"gitea-user"}
|
GET|/api/v1/user|200|{"login":"gitea-user"}
|
||||||
GET|/api/v1/licenses|200|[{"key":"AGPL-3.0"}]
|
GET|/api/v1/licenses|200|[{"key":"AGPL-3.0"}]
|
||||||
@@ -473,3 +476,42 @@ mirror_field '$WORK/m.json' https://github.com/o/b.git sync_on_commit"
|
|||||||
assert_eq "the right mirror is chosen with jq" "true" "$OUT"
|
assert_eq "the right mirror is chosen with jq" "true" "$OUT"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------- found by the live e2e run
|
||||||
|
|
||||||
|
test_a_repository_this_script_created_earlier_can_be_reused() {
|
||||||
|
# Seen on a real Gitea: creating a repository with a license also adds a
|
||||||
|
# README.md. After a failed mirror step the next run must still reuse it.
|
||||||
|
setup_hosts
|
||||||
|
prepend_route 'GET|/api/v1/repos/TirSystem/my-app|200|{"empty":false}'
|
||||||
|
prepend_route 'GET|/api/v1/repos/TirSystem/my-app/contents|200|[{"name":"README.md","type":"file"},{"name":"LICENSE","type":"file"}]'
|
||||||
|
run_apply "$ANSWERS_GITHUB"$'y\ny\n'
|
||||||
|
assert_status "LICENSE and README.md" 0 "$STATUS"
|
||||||
|
assert_not_contains "not created again" "$(calls)" "$GITEA_REPO_CALL"
|
||||||
|
assert_contains "reported" "$OUT" "Gitea repository : reused"
|
||||||
|
}
|
||||||
|
|
||||||
|
test_other_files_still_count_as_content() {
|
||||||
|
local listing
|
||||||
|
for listing in '[{"name":"README.md","type":"file"}]' \
|
||||||
|
'[{"name":"LICENSE","type":"file"},{"name":"README.md","type":"file"},{"name":"main.c","type":"file"}]' \
|
||||||
|
'[{"name":"LICENSE","type":"file"},{"name":"src","type":"dir"}]'; do
|
||||||
|
setup_hosts
|
||||||
|
prepend_route 'GET|/api/v1/repos/TirSystem/my-app|200|{"empty":false}'
|
||||||
|
prepend_route "GET|/api/v1/repos/TirSystem/my-app/contents|200|$listing"
|
||||||
|
run_dry "$ANSWERS_GITHUB"
|
||||||
|
assert_status "content: $listing" 1 "$STATUS"
|
||||||
|
assert_contains "refused" "$ERR" "already exists and has content"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
test_children_never_eat_the_answers_meant_for_later_prompts() {
|
||||||
|
# The real ssh reads standard input until it ends; the stub does too. The
|
||||||
|
# script closes stdin for ssh, git, curl and the framework scripts, so the
|
||||||
|
# answers that follow the SSH test still reach the later prompts.
|
||||||
|
setup_hosts
|
||||||
|
run_apply "$ANSWERS_GITHUB"$'y\n'
|
||||||
|
assert_status "the final question is still answered" 0 "$STATUS"
|
||||||
|
assert_contains "created" "$OUT" "Done. This is what exists now:"
|
||||||
|
assert_not_contains "no lost input" "$ERR" "no input available"
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 global-hooks-dir
|
||||||
|
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 'global-hooks-dir'"
|
||||||
|
assert_eq "the global setting is untouched" "global-hooks-dir" "$(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)"
|
||||||
|
}
|
||||||
@@ -103,7 +103,7 @@ test_usage_errors() {
|
|||||||
assert_contains "help shows exit codes" "$OUT" "Exit codes"
|
assert_contains "help shows exit codes" "$OUT" "Exit codes"
|
||||||
run_cli "" --version
|
run_cli "" --version
|
||||||
assert_status "version" 0 "$STATUS"
|
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() {
|
test_warns_when_env_is_not_ignored_by_git() {
|
||||||
|
|||||||
Reference in New Issue
Block a user