# Windows

El camino completo en Windows: el zip, PowerShell y cmd, una tarea programada y lo que de verdad cambia allí.

Source: https://jmrplens.github.io/ghchronicle/es/install/windows/

Windows es una plataforma publicada, no algo añadido después: cada cambio
ejecuta la suite unitaria entera y la de extremo a extremo en un runner de
Windows, y el código fuente lleva partes solo de Windows allí donde el sistema
se comporta distinto. Lo que sigue está escrito para PowerShell, con la forma
de `cmd` al lado siempre que las dos difieran.

## Lo que cambia aquí

Lee esto primero. Cuatro de las cinco líneas de abajo son la razón de que una
orden copiada de una página de Linux no funcione.

- **El programa** es `ghchronicle.exe`. Escribir `ghchronicle` lo encuentra,
  porque `PATHEXT` lista `.EXE`.
- **Dependencias en ejecución**: ninguna. El binario se compila con
  `CGO_ENABLED=0`, así que no hay runtime de C que instalar.
- **Pararlo** es Ctrl+C en su consola. No hay señal que enviarle, y
  `taskkill /F` lo termina sin cerrar sus destinos.
- **Rutas en la configuración**: una barra invertida dentro de un escalar YAML
  entre comillas dobles es un escape. Usa barras normales.
- **Ejecución desatendida**: una tarea programada. El binario no es un servicio
  del Administrador de control de servicios.

## Elegir el archivo

Los archivos de Windows son `zip` y no `tar.gz`, y se llaman
`ghchronicle_<versión>_windows_<arq>.zip`, donde `<arq>` es `amd64` o `arm64`.

| `$env:PROCESSOR_ARCHITECTURE` | El archivo que toca |
| ----------------------------- | ------------------- |
| `AMD64`                       | `windows_amd64`     |
| `ARM64`                       | `windows_arm64`     |

Esa variable describe el **proceso**, no la máquina. Un PowerShell de 32 bits
en una máquina de 64 informa de `x86` y deja la arquitectura real de la
máquina en `$env:PROCESSOR_ARCHITEW6432`; un PowerShell x64 emulado en una
máquina ARM64 informa de `AMD64` y no pone nada más, que es el caso que te
entrega sin ruido el archivo equivocado. Si alguno de los dos puedes ser tú,
pregúntale a la máquina y no al intérprete:

```powershell
(Get-CimInstance Win32_Processor).Architecture   # 9 es x64, 12 es ARM64
```

```powershell
$version = "1.0.0"
$arch = if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64") { "arm64" } else { "amd64" }
$base = "https://github.com/jmrplens/ghchronicle/releases/download/v$version"
$zip = "ghchronicle_${version}_windows_${arch}.zip"
Invoke-WebRequest -UseBasicParsing -Uri "$base/$zip" -OutFile $zip
```

`-UseBasicParsing` no es adorno. En Windows PowerShell 5.1 `Invoke-WebRequest`
arma su resultado a través del motor de Internet Explorer salvo que se le diga
que no, y falla del todo en una máquina donde Internet Explorer se haya quitado
o nunca haya pasado su configuración de primer arranque, que es el caso de
Server Core y de casi cualquier imagen endurecida. En PowerShell 7 el
modificador se acepta y no hace nada.

> **Nombra la versión en vez de pedir la más nueva**
>
> El repositorio publica una etiqueta móvil `v1` junto a las releases
> numeradas, porque la Action está listada en el Marketplace y ese listado
> necesita una. La release `v1` **no lleva ningún fichero**, así que nunca
> construyas una URL de descarga a partir de la etiqueta mayor: detrás no hay
> nada que descargar. Coge la versión de la
> [página de releases](https://github.com/jmrplens/ghchronicle/releases) y
> escríbela, como arriba.

## Comprobar lo que has descargado

`checksums.txt` lleva el SHA-256 de cada archivo, y
`checksums.txt.sigstore.json` es una firma sobre ese fichero.

1. Coge el fichero de sumas y compara la única línea que es tuya.

    ```powershell
    Invoke-WebRequest -UseBasicParsing -Uri "$base/checksums.txt" -OutFile checksums.txt
    $expected = (Select-String -Path checksums.txt -Pattern ([regex]::Escape($zip) + '$')).Line.Split(" ")[0]
    $actual = (Get-FileHash -Algorithm SHA256 -Path $zip).Hash
    if ($actual -eq $expected) { "OK" } else { "MISMATCH" }
    ```

    `Get-FileHash` devuelve el resumen en mayúsculas y `checksums.txt` lo
    guarda en minúsculas. Aun así comparan igual porque el `-eq` de PowerShell
    entre dos cadenas ignora las mayúsculas, que es el único sitio de esta
    página donde ese comportamiento por omisión resulta cómodo en vez de una
    trampa.

2. Comprueba el propio fichero de sumas, si tienes
   [cosign](https://docs.sigstore.dev/cosign/system_config/installation/). La
   orden es la misma que imprimen las notas de la release, y la misma que
   ejecuta quien lea la página de Linux o la de macOS.

    ```powershell
    cosign verify-blob `
      --certificate-identity-regexp 'https://github.com/jmrplens/ghchronicle/.github/workflows/release.yml@refs/tags/.*' `
      --certificate-oidc-issuer https://token.actions.githubusercontent.com `
      --bundle checksums.txt.sigstore.json `
      checksums.txt
    ```

    El acento grave es la continuación de línea de PowerShell, donde un guion
    de intérprete usa la barra invertida.

## Ponerlo en un sitio y en el PATH

El archivo lleva tres ficheros y ningún directorio, así que descomprímelo en un
directorio que hayas hecho.

- ghchronicle_1.0.0_windows_amd64.zip
  - ghchronicle.exe el binario
  - LICENSE
  - README.md

- **Para todos**

  Un PowerShell elevado, porque tanto el directorio como el `Path` de máquina
  piden permisos de administrador.

  ```powershell
  $dir = "C:\Program Files\ghchronicle"
  Expand-Archive -Path $zip -DestinationPath $dir -Force
  $machine = [Environment]::GetEnvironmentVariable("Path", "Machine")
  [Environment]::SetEnvironmentVariable("Path", "$machine;$dir", "Machine")
  ```

- **Para una cuenta**

  Sin elevación, y sin tocar nada fuera de tu perfil.

  ```powershell
  $dir = "$env:LOCALAPPDATA\Programs\ghchronicle"
  Expand-Archive -Path $zip -DestinationPath $dir -Force
  $user = [Environment]::GetEnvironmentVariable("Path", "User")
  [Environment]::SetEnvironmentVariable("Path", "$user;$dir", "User")
  ```

> **Dos trampas al escribir Path de vuelta**
>
> Los dos fragmentos leen `Path` del ámbito en el que escriben, nunca de
> `$env:Path`. `$env:Path` es la copia propia del proceso, que Windows armó
> juntando la lista de máquina y la de usuario: escribe eso de vuelta en
> cualquiera de los dos ámbitos y habrás copiado el otro dentro, para siempre,
> y vuelve a crecer cada vez que alguien repita la orden.
>
> La segunda trampa es solo del ámbito de máquina.
> `[Environment]::GetEnvironmentVariable` expande `%SystemRoot%` y sus parientes
> al leer, y `SetEnvironmentVariable` escribe el resultado de vuelta como una
> cadena llana, así que un `Path` de máquina que llevara esas entradas vuelve
> con ellas ya resueltas y su valor del registro cambia de tipo, de
> `REG_EXPAND_SZ` a `REG_SZ`, para siempre. En `$machine` no se ve, porque la
> expansión ya ocurrió antes. Abre Propiedades del sistema, Variables de
> entorno, que muestra el valor sin expandir: si hay algún `%VAR%` en el `Path`
> de máquina, añade el directorio desde ese cuadro de diálogo y no desde el
> fragmento de arriba.

Un `Path` nuevo solo llega a los procesos que arranquen después, así que abre
una terminal nueva antes de la comprobación de abajo. La actual se queda con el
entorno que le dieron.

```powershell
ghchronicle -version
```

```text
ghchronicle 1.0.0 (commit 4e5dfc2, built 2026-09-14T23:04:02Z)
```

> **Si Windows avisa sobre el fichero**
>
> El binario no lleva firma Authenticode: la release firma el fichero de sumas
> y los SBOM, y nada más. SmartScreen y Defender tratan un ejecutable sin
> firmar descargado de internet con sus propios criterios, que varían por
> versión y por directiva y que esta página no va a adivinar. Si Windows se
> niega a arrancarlo, el atributo que hay que quitar es la marca de la web:
>
> ```powershell
> Unblock-File -Path "$dir\ghchronicle.exe"
> ```

## El token, y el resto del entorno

Cada `${VAR}` del fichero de configuración se lee del entorno cuando arranca el
proceso, así que el token nunca tiene que estar en el fichero.

- **PowerShell**

  ```powershell
  # Solo esta ventana
  $env:GITHUB_TOKEN = "github_pat_..."

  # Persistido para esta cuenta
  [Environment]::SetEnvironmentVariable("GITHUB_TOKEN", "github_pat_...", "User")
  ```

- **cmd**

  ```bat
  rem Solo esta ventana
  set GITHUB_TOKEN=github_pat_...

  rem Persistido para esta cuenta, y NO visible en esta ventana
  setx GITHUB_TOKEN "github_pat_..."
  ```

> **Una variable persistida es un token en el registro**
>
> `SetEnvironmentVariable` con `User` o `Machine`, y `setx`, escriben en el
> registro en claro, donde puede leerlas cualquier cosa que corra con esa
> cuenta. Es el mismo trato que un fichero de entorno de systemd, sin el modo
> del fichero en el que apoyarse. Una tarea programada que corra como `SYSTEM`
> lee el ámbito de máquina, así que un token puesto ahí lo puede leer cualquier
> servicio de la máquina: prefiere el ámbito de usuario y una tarea que corra
> como ese usuario.

## Ejecutarlo una vez

El binario necesita un fichero de configuración, y
[el inicio rápido](/ghchronicle/es/start/quickstart/) escribe uno en seis
pasos. Guárdalo en UTF-8, y atención a los dos detalles de Windows de debajo.

```powershell
ghchronicle -config config.yaml -list   # qué se recogería
ghchronicle -config config.yaml -once   # una pasada y termina
```

Si el binario está en el directorio actual y no en el `PATH`, PowerShell
necesita que se le nombre como ruta: `.\ghchronicle.exe`. Un nombre a secas es
una orden, y el directorio actual no se busca para órdenes.

### Rutas en el fichero de configuración

YAML trata la barra invertida como un escape dentro de un escalar entre
**comillas dobles** y como un carácter corriente en cualquier otro sitio. Así
que una ruta de Windows entre comillas dobles no es la ruta que escribiste, y
normalmente tampoco es YAML válido:

```yaml
state_file: "C:\ghchronicle\state.json" # ghchronicle: config.yaml: yaml: line N: found unknown escape character
state_file: C:\ghchronicle\state.json # correcto, escalar simple
state_file: 'C:\ghchronicle\state.json' # correcto, comillas simples
state_file: C:/ghchronicle/state.json # correcto, y el que conviene
```

Las barras normales son la respuesta más simple: Windows las acepta en una
ruta, y sobreviven a entrecomillarlas de cualquiera de las maneras.

### El fichero tiene que ser UTF-8

Windows PowerShell 5.1, el que viene de serie, no escribe ninguna de las dos
cosas que quiere un analizador de YAML, y escribe una cosa equivocada distinta
según cómo se lo pidas. `>` y `Out-File` producen UTF-16LE, que el analizador
lee como binario. `Set-Content` produce la página de códigos activa del
sistema, normalmente ANSI, que se analiza bien mientras el fichero sea ASCII
puro y destroza el primer carácter acentuado que aparezca. PowerShell 7 usa
UTF-8 sin marca de orden de bytes por omisión y no tiene ninguno de los dos
problemas; en 5.1, sé explícito:

```powershell
Set-Content -Path config.yaml -Value $text -Encoding utf8
```

`utf8` en 5.1 quiere decir UTF-8 **con** marca de orden de bytes, cosa que el
valor no sabe expresar y de la que el cmdlet no avisa. El analizador del
colector la salta, así que el fichero funciona; una herramienta que lea los
primeros bytes por su cuenta puede que no.

## Dejarlo corriendo

No hay modo servicio. El colector es un programa de consola: no habla con el
Administrador de control de servicios, así que registrarlo con `sc.exe create`
produce un servicio que Windows arranca y luego abandona, informando de que «no
respondió a la petición de inicio o control de manera oportuna». Existen
envoltorios de servicio de terceros y este proyecto ni distribuye ni prueba
ninguno.

La forma corriente de ejecutar algo desatendido en Windows es una tarea
programada, y tiene dos formas.

- **Una pasada con temporizador**

  El equivalente de cron en Windows, y el que conviene: no hay nada que
  parar, y una ejecución perdida cuesta una pasada.

  ```powershell
  $action = New-ScheduledTaskAction `
    -Execute "C:\Program Files\ghchronicle\ghchronicle.exe" `
    -Argument '-config "C:\ProgramData\ghchronicle\config.yaml" -once'
  $trigger = New-ScheduledTaskTrigger -Once -At (Get-Date) `
    -RepetitionInterval (New-TimeSpan -Hours 1) `
    -RepetitionDuration ([TimeSpan]::MaxValue)
  Register-ScheduledTask -TaskName ghchronicle -Action $action -Trigger $trigger
  ```

  `-RepetitionDuration ([TimeSpan]::MaxValue)` es lo que deja escrito «para
  siempre». La regla del propio Programador de tareas es que una repetición
  sin duración se repite indefinidamente, así que omitirlo no es un error,
  pero el cmdlet no tiene valor propio por omisión y la tarea queda
  registrada sin ninguna duración.

  Esta forma trae dos condiciones, y ninguna es la de cron. Una tarea horaria
  da a todas las familias una cadencia horaria como mucho, así que el ritmo
  de quince minutos de `actions` se pierde, que es el mismo cambio que
  [hace cron en Linux](/ghchronicle/es/install/systemd/#cron-en-vez-de-un-servicio).
  Y aquí `Register-ScheduledTask` no nombra ni `-User` ni `-Principal`, así
  que la tarea queda registrada con la cuenta que la crea y con el tipo de
  inicio de sesión por omisión, y corre **solo mientras esa cuenta tenga la
  sesión iniciada**. Un trabajo de cron no se para al cerrar sesión. Para
  que esta se comporte igual, regístrala con una entidad de seguridad con
  «ejecutar tanto si el usuario inició sesión como si no» marcado, que es
  `New-ScheduledTaskPrincipal`.

- **Corriendo todo el rato**

  Una tarea disparada al iniciar sesión o al arrancar, con el colector en su
  modo permanente para que cada familia conserve su cadencia.

  ```powershell
  $action = New-ScheduledTaskAction `
    -Execute "C:\Program Files\ghchronicle\ghchronicle.exe" `
    -Argument '-config "C:\ProgramData\ghchronicle\config.yaml"'
  $trigger = New-ScheduledTaskTrigger -AtLogOn
  $settings = New-ScheduledTaskSettingsSet `
    -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1) `
    -ExecutionTimeLimit ([TimeSpan]::Zero)
  Register-ScheduledTask -TaskName ghchronicle -Action $action `
    -Trigger $trigger -Settings $settings
  ```

  `-ExecutionTimeLimit ([TimeSpan]::Zero)` es el que importa: el valor por
  omisión detiene la tarea a los tres días, que para un proceso pensado para
  no terminar es un reinicio que nadie pidió.

```powershell
Start-ScheduledTask -TaskName ghchronicle
Get-ScheduledTaskInfo -TaskName ghchronicle   # última ejecución, último resultado
```

`Stop-ScheduledTask` termina el proceso en vez de pedirle que acabe: es
`taskkill /F` con otro nombre, y no cierra ningún destino al salir. En la forma
con temporizador no hay nada que parar, que es buena parte de por qué es la
mejor opción por omisión.

> **Dónde va el log**
>
> Una tarea no tiene consola, así que el log tiene que ser un fichero: pon
> `log.file` en la configuración. La salida estándar de una tarea programada se
> descarta, y `Get-ScheduledTaskInfo` informa solo del código de salida.

## Compilarlo desde fuente

Go 1.27.1 o más nuevo, que es la versión que declara `go.mod`. `CGO_ENABLED=0`
quiere decir sin MSVC, sin MinGW y sin SDK de Windows.

- **go install**

  ```powershell
  go install github.com/jmrplens/ghchronicle/cmd/ghchronicle@latest
  ```

  Cae en `$(go env GOPATH)\bin`, que es `%USERPROFILE%\go\bin` salvo que lo
  hayas movido, y ese directorio tiene que estar en tu `Path`. Un binario
  hecho así informa de su versión pero no de su commit ni de su fecha de
  compilación, porque esas dos las sella la compilación de release y un
  módulo descargado por el proxy no lleva copia de trabajo de donde leerlas.

- **Desde una copia de trabajo**

  ```powershell
  git clone https://github.com/jmrplens/ghchronicle
  cd ghchronicle
  go build -o ghchronicle.exe .\cmd\ghchronicle
  ```

  `go build` y no `make`: el Makefile es un Makefile de GNU cuyas recetas son
  intérprete POSIX, así que quiere Git Bash, MSYS2 o WSL. Esta línea es lo
  que hace `make build`, menos el sellado de la versión.

## Dónde van los ficheros

El colector no busca el fichero de configuración en ningún sitio concreto:
`-config` vale por omisión `config.yaml` **relativo al directorio de trabajo**,
y detrás no hay ninguna ruta de búsqueda. El directorio de trabajo de una tarea
programada no es algo en lo que apoyarse, así que da todas las rutas del
fichero en absoluto.

| Fichero           | Para una tarea de máquina     | Para una cuenta                |
| ----------------- | ----------------------------- | ------------------------------ |
| Configuración     | `C:/ProgramData/ghchronicle/` | `${LOCALAPPDATA}/ghchronicle/` |
| Estado y registro | `C:/ProgramData/ghchronicle/` | `${LOCALAPPDATA}/ghchronicle/` |
| Log               | `C:/ProgramData/ghchronicle/` | `${LOCALAPPDATA}/ghchronicle/` |

`${LOCALAPPDATA}` se escribe así porque `${VAR}` es la única forma que el
colector expande, desde el entorno, al arrancar el proceso. `%VAR%` es una
notación del intérprete y para el fichero no significa nada: un
`%LOCALAPPDATA%` copiado dentro del YAML te da un directorio llamado
literalmente `%LOCALAPPDATA%`, al lado de donde la tarea estuviera trabajando.
Son `%LOCALAPPDATA%` en `cmd` y `$env:LOCALAPPDATA` en PowerShell los que crean
el directorio en primer lugar.

Un directorio bajo `C:\ProgramData` lo puede escribir quien lo creó y lo puede
leer todo el mundo, así que créalo elevado y luego concede escritura a la
cuenta con la que corre la tarea. El fichero de estado y su registro de
escrituras son los dos que el colector reescribe en cada pasada; si uno de
ellos está marcado como de solo lectura el colector quita ese atributo él
mismo, porque NTFS se niega a reemplazar un fichero de solo lectura incluso
cuando se le pide reemplazarlo, y una pasada no debería fallar por una
propiedad de fichero.
